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`
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:**
| 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_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:**
@@ -1778,7 +1781,7 @@ List structured memories with optional filters. Requires `read` scope.
"memory_id": "a1b2c3d4-e5f6-...",
"name": "project_architecture",
"description": "Core architecture patterns",
"type": "project",
"type": "general",
"scope": "global",
"scope_id": "",
"content": "The project uses a hexagonal architecture...",
@@ -1795,7 +1798,8 @@ List structured memories with optional filters. Requires `read` scope.
### `POST /v1/api/memories`
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:**
@@ -1804,7 +1808,7 @@ create, `200` on update.
"name": "deployment_process",
"content": "Deploy via GitHub Actions. Staging auto-deploys on push to main.",
"description": "CI/CD deployment workflow",
"type": "project",
"type": "general",
"scope": "global",
"scope_id": ""
}
@@ -1814,8 +1818,8 @@ create, `200` on update.
|--------------|--------|----------|-------------|--------------------------------------|
| `name` | string | yes | -- | Memory name (max 256 chars) |
| `content` | string | yes | -- | Memory content (max 65536 chars) |
| `description`| string | no | `""` | Short description for search ranking |
| `type` | string | no | `"project"` | One of: user, project, feedback, reference |
| `description`| string | yes | -- | Non-empty relevance summary, required on create and update |
| `type` | string | no | unset | user, general, feedback, or reference |
| `scope` | string | no | `"global"` | One of: global, workstream, user |
| `scope_id` | string | no | `""` | Scope qualifier (auto-resolved for user scope) |
@@ -1826,7 +1830,7 @@ create, `200` on update.
"memory_id": "a1b2c3d4-e5f6-...",
"name": "deployment_process",
"description": "CI/CD deployment workflow",
"type": "project",
"type": "general",
"scope": "global",
"scope_id": "",
"content": "Deploy via GitHub Actions...",
@@ -1839,21 +1843,25 @@ create, `200` on update.
| 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`
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:**
```json
{
"query": "authentication",
"type": "project",
"type": "general",
"scope": "",
"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 |
| `scope` | string | no | `""` | Filter by scope |
| `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:**
@@ -1876,7 +1884,7 @@ Search memories by query. Uses POST for the request body but is non-mutating
"memory_id": "a1b2c3d4-e5f6-...",
"name": "auth_patterns",
"description": "Authentication architecture",
"type": "project",
"type": "general",
"scope": "global",
"scope_id": "",
"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 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:**
@@ -1978,7 +1988,7 @@ Get a single memory by ID. Requires `admin.memories` permission.
"memory_id": "a1b2c3d4-e5f6-...",
"name": "project_architecture",
"description": "Core architecture patterns",
"type": "project",
"type": "general",
"scope": "global",
"scope_id": "",
"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. |
| `list_nodes` | discover | Enumerate live cluster nodes + capabilities. |
| `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. |
| `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) ==
Session -> Session : _prepare_tool_calls()\nparse memory(action=...)
Session -> Session : pin acting principal\nparse memory(action=...)
note right
Tool schema: 4 actions
save, search, delete, list
Tool schema: 5 actions
save, get, search, delete, list
Auto-approved (no approval needed)
end note
Session -> Session : resolve live project access\nselect exact/inherited scope
Session -> Session : _exec_memory(item)
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 -> Storage : create_structured_memory()
alt unique constraint violation
Storage --> Facade : IntegrityError
Facade -> Storage : get_structured_memory_by_name()
Storage --> Facade : existing row
Facade -> Storage : update_structured_memory()
end
Storage --> Facade : memory_id
Facade --> Session : (memory_id, old_content)
Session -> Session : _init_system_messages()\nrefresh BM25 context
Facade -> Storage : guarded atomic upsert\nON CONFLICT ... RETURNING
Storage --> Facade : (saved row, was_update)
Facade --> Session : saved row
Session -> Session : invalidate prefix/cache\naudit acting principal
end
alt action = get
Session -> Facade : get_structured_memory_by_name_strict()
Facade -> Storage : exact scoped-name lookup
Storage --> Session : full row / not found
end
alt action = search
Session -> Facade : search_structured_memories(\nquery, mem_type, scope,\nscope_id, limit)
Facade -> Storage : search_structured_memories()
Session -> Storage : search exact scope or\nactor-visible scope union
Storage --> Session : matched rows
end
alt action = delete
Session -> Facade : delete_structured_memory(\nname, scope, scope_id)
Facade -> Storage : delete_structured_memory()
Storage --> Session : bool (existed)
Session -> Session : _init_system_messages()\nrefresh BM25 context
Session -> Facade : delete_structured_memory_returning_strict()
Facade -> Storage : DELETE ... RETURNING
Storage --> Session : deleted row / not found
Session -> Session : invalidate + audit\nmark prefix dirty
end
== Phase 2: BM25 Relevance Injection ==
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)
note right
**Scope resolution:**
1. global scope (always)
2. workstream scope (ws_id)
3. user scope (user_id, if auth)
Combined and deduplicated.
Interactive: global + workstream
+ acting user + readable project
Coordinator: acting user's coordinator
+ readable project
end note
Session -> Facade : list_structured_memories()\nper scope
Facade -> Storage : list_structured_memories()
Session -> Facade : list_visible_structured_memories()
Facade -> Storage : one visibility-union query
Storage --> Session : up to fetch_limit rows
Session -> Relevance : extract_recent_context(\nmessages, max_messages=3)
@@ -103,28 +106,31 @@ Session -> Session : inject into\nsystem message
== Phase 3: Server API Path ==
SDK -> API : GET /v1/api/memories\n?type=project&limit=20
API -> Facade : list_structured_memories()
Facade -> Storage : list_structured_memories()
SDK -> API : GET /v1/api/memories\n?type=general&limit=20
API -> API : bind scope to caller\ndefault global + caller user
API -> Storage : list visible rows
Storage --> API : rows
API --> SDK : {"memories": [...], "total": N}
SDK -> API : POST /v1/api/memories\n{name, content, ...}
API -> API : validate type, scope,\nname length, content length
API -> Facade : save_structured_memory()
Facade -> Storage : create / update
SDK -> API : POST /v1/api/memories\n{name, content, description, ...}
API -> API : validate type, scope,\nname/content/description
API -> API : reject internal scopes\nowner-bind workstream scope
API -> Facade : save_structured_memory_strict()
Facade -> Storage : atomic upsert
Storage --> API : memory row
API -> API : record_audit(actor)
API --> SDK : 201 (created) / 200 (updated)
SDK -> API : POST /v1/api/memories/search\n{query, type, ...}
API -> Facade : search_structured_memories()
Facade -> Storage : search_structured_memories()
API -> API : bind scope to caller
API -> Storage : search visible rows
Storage --> API : matched rows
API --> SDK : {"memories": [...], "total": N}
SDK -> API : DELETE /v1/api/memories/{name}\n?scope=global
API -> Facade : delete_structured_memory()
Facade -> Storage : delete row
API -> Facade : delete_structured_memory_returning_strict()
Facade -> Storage : DELETE ... RETURNING
API -> API : record_audit(actor)
API --> SDK : {"status": "ok"}
== Phase 4: Console Admin Path ==
@@ -141,7 +147,7 @@ Storage --> Admin : memory row
Admin --> SDK : memory JSON
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 --> SDK : {"status": "ok"}
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bd7fe8bf5c2b56b075453a316e54d61214b0ae912517d9cad6c1e88785aac722
size 300010
oid sha256:137d6c91a34695c820d8b0a33fd753e79165604aa92bf2ac8480d3744b2ef844
size 305199
+94 -42
View File
@@ -20,7 +20,7 @@ Each memory has three dimensions:
| Type | Purpose |
|-------------|------------------------------------------------------------|
| `user` | User preferences, conventions, working style |
| `project` | Project-specific knowledge, architecture, patterns |
| `general` | General knowledge, architecture, patterns |
| `feedback` | Corrections, lessons learned, things to avoid |
| `reference` | Reference material, documentation, specifications |
@@ -31,25 +31,37 @@ Each memory has three dimensions:
| `global` | Visible to all workstreams and users |
| `workstream` | Visible only within the originating workstream |
| `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
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
the coordinator's creator `user_id`. It is durable -- every coordinator
session the same user runs (including concurrent ones) shares one
orchestration namespace, so procedures and lessons survive close/reopen.
Name-based operations use one inherited target when `scope` is omitted:
- An attached active project selects `project` for `save`, `get`, and
`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
the scope id:
- A coordinator session can read and write **only** `coordinator`-scope rows.
It never sees `global`/`workstream`/`user` memories, so content written by
interactive sessions (which routinely ingest untrusted MCP/attachment
output) cannot reach a coordinator's system message.
- A coordinator session sees its acting user's `coordinator` scope and, when
attached, the shared `project` scope. It never sees
`global`/`workstream`/`user` memories.
- Interactive sessions -- including a coordinator's own children, which share
its `user_id` -- are rejected from the `coordinator` scope on every memory
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:
1. Fetches up to `fetch_limit` memories visible in the current scope
2. Extracts context from the last 3 user messages
3. Scores memories against that context using a BM25 index
4. Injects the top `relevance_k` memories into the system message as
1. Resolves the acting principal and their live project access
2. Fetches up to `fetch_limit` memories across that visibility envelope
3. Extracts context from the last 3 user messages
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
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
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
The `memory` tool supports four actions:
The `memory` tool supports five actions:
### save
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
{
"action": "save",
"name": "project_architecture",
"content": "The project uses a hexagonal architecture with...",
"description": "Core architecture patterns",
"type": "project",
"type": "general",
"scope": "global"
}
```
@@ -128,9 +145,26 @@ Store or update a memory.
|---------------|----------|-------------|------------------------------------------|
| `name` | yes | -- | Snake_case identifier (max 256 chars) |
| `content` | yes | -- | Memory content (max `max_content` chars) |
| `description` | no | `""` | Short description for relevance matching |
| `type` | no | `"project"` | One of: user, project, feedback, reference |
| `scope` | no | `"global"` | One of: global, workstream, user |
| `description` | yes | -- | Non-empty relevance summary, required on create and update |
| `type` | no | `"general"` | One of: user, general, feedback, reference |
| `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
@@ -140,7 +174,7 @@ Find memories by query (BM25 full-text search).
{
"action": "search",
"query": "authentication patterns",
"type": "project",
"type": "general",
"limit": 10
}
```
@@ -167,7 +201,7 @@ Remove a memory by name.
| Parameter | Required | Default | Description |
|------------|----------|------------|--------------------------|
| `name` | yes | -- | Memory name to delete |
| `scope` | no | `"global"` | Scope of the memory |
| `scope` | no | inherited | Exact scope to delete |
### list
@@ -197,6 +231,12 @@ Four endpoints on the server for programmatic memory access.
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:**
| Parameter | Type | Required | Default | Description |
@@ -204,10 +244,10 @@ List memories with optional filters.
| `type` | string | no | `""` | Filter by memory type |
| `scope` | string | no | `""` | Filter by scope |
| `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
used automatically.
When `scope=user`, the authenticated user's ID is used automatically and a
different supplied ID is rejected. `scope=workstream` requires `scope_id`.
**Response:** `200`
@@ -218,7 +258,7 @@ used automatically.
"memory_id": "a1b2c3d4-e5f6-...",
"name": "project_architecture",
"description": "Core architecture patterns",
"type": "project",
"type": "general",
"scope": "global",
"scope_id": "",
"content": "The project uses a hexagonal architecture...",
@@ -236,6 +276,9 @@ used automatically.
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:**
```json
@@ -243,7 +286,7 @@ Save or upsert a structured memory.
"name": "deployment_process",
"content": "Deploy via GitHub Actions. Staging auto-deploys on push to main.",
"description": "CI/CD deployment workflow",
"type": "project",
"type": "general",
"scope": "global",
"scope_id": ""
}
@@ -253,8 +296,8 @@ Save or upsert a structured memory.
|--------------|--------|----------|-------------|--------------------------------------|
| `name` | string | yes | -- | Memory name (max 256 chars) |
| `content` | string | yes | -- | Memory content (max 65536 chars) |
| `description`| string | no | `""` | Short description for search ranking |
| `type` | string | no | `"project"` | One of: user, project, feedback, reference |
| `description`| string | yes | -- | Non-empty relevance summary, required on create and update |
| `type` | string | no | unset | user, general, feedback, or reference |
| `scope` | string | no | `"global"` | One of: global, workstream, user |
| `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-...",
"name": "deployment_process",
"description": "CI/CD deployment workflow",
"type": "project",
"type": "general",
"scope": "global",
"scope_id": "",
"content": "Deploy via GitHub Actions...",
@@ -281,7 +324,10 @@ same `(name, scope, scope_id)` already existed.
| 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
(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:**
```json
{
"query": "authentication",
"type": "project",
"type": "general",
"scope": "",
"scope_id": "",
"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 |
| `scope` | string | no | `""` | Filter by scope |
| `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`
@@ -319,7 +368,7 @@ Search memories by query. Uses POST for the request body but is non-mutating
"memory_id": "a1b2c3d4-e5f6-...",
"name": "auth_patterns",
"description": "Authentication architecture",
"type": "project",
"type": "general",
"scope": "global",
"scope_id": "",
"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.
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:**
| Parameter | Type | Description |
@@ -391,7 +443,7 @@ List memories across all scopes (no automatic scope resolution).
"memory_id": "a1b2c3d4-e5f6-...",
"name": "project_architecture",
"description": "Core architecture patterns",
"type": "project",
"type": "general",
"scope": "global",
"scope_id": "",
"content": "The project uses...",
@@ -440,7 +492,7 @@ Get a single memory by ID.
"memory_id": "a1b2c3d4-e5f6-...",
"name": "project_architecture",
"description": "Core architecture patterns",
"type": "project",
"type": "general",
"scope": "global",
"scope_id": "",
"content": "The project uses...",
@@ -497,13 +549,13 @@ with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
"api_conventions",
"All endpoints use /v1/ prefix. JSON responses.",
description="API design patterns",
mem_type="project",
mem_type="general",
scope="global",
)
print(mem.memory_id)
# 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:
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)
# Search
result = admin.search_memories("architecture", mem_type="project")
result = admin.search_memories("architecture", mem_type="general")
# Get by ID
mem = admin.get_memory("a1b2c3d4-e5f6-...")
@@ -548,14 +600,14 @@ const mem = await client.saveMemory({
name: "api_conventions",
content: "All endpoints use /v1/ prefix. JSON responses.",
description: "API design patterns",
type: "project",
type: "general",
scope: "global",
});
// Search memories
const results = await client.searchMemories({
query: "authentication",
type: "project",
type: "general",
limit: 10,
});
+18 -10
View File
@@ -152,7 +152,7 @@ into its successor's trajectory.
**Auto-approved** (no user confirmation needed at runtime):
- `read_file` -- reads files, 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
- `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. |
- **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.
- **Agent availability**: Top-level only.
@@ -447,18 +447,26 @@ Structured persistent memory across sessions with typed, scoped entries.
| Parameter | Type | Required | Description |
|---------------|---------|----------|-------------|
| `action` | string | yes | `save`, `search`, `delete`, or `list`. |
| `name` | string | save/delete | Short snake_case identifier for the memory. |
| `action` | string | yes | `save`, `get`, `search`, `delete`, or `list`. |
| `name` | string | save/get/delete | Short snake_case identifier for the memory. |
| `content` | string | save | Memory content to store. |
| `description` | string | no | Short description for relevance matching (recommended for `save`). |
| `type` | string | no | Memory type: `user`, `project`, `feedback`, or `reference`. Default: `project`. |
| `scope` | string | no | Memory scope: `global`, `workstream`, or `user`. Default: `global`. |
| `description` | string | save | Non-empty description for relevance matching; required on create and update. |
| `type` | string | no | Memory type: `user`, `general`, `feedback`, or `reference`. Default: `general`. |
| `scope` | string | no | Memory scope: `global`, `workstream`, `user`, `coordinator`, or `project`. See defaults below. |
| `query` | string | search | Search query for finding memories. |
| `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.
- **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.
- **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",
"info": {
"title": "turnstone Console API",
"version": "1.8.0a6",
"version": "1.8.0a7",
"description": "Cluster-wide visibility and control across all turnstone nodes."
},
"paths": {
+162 -10
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "turnstone Server API",
"version": "1.8.0a6",
"version": "1.8.0a7",
"description": "Single-node workstream management, chat interaction, and real-time streaming."
},
"paths": {
@@ -1869,7 +1869,7 @@
},
"/v1/api/memories": {
"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",
"tags": [
"Memories"
@@ -1891,7 +1891,7 @@
"schema": {
"type": "string"
},
"description": "Filter by scope"
"description": "Filter by public scope: global, workstream, or user"
},
{
"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": {
"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",
"tags": [
"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": {
"description": "Error 404",
"content": {
@@ -2052,6 +2182,16 @@
}
}
}
},
"500": {
"description": "Error 500",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
@@ -3854,33 +3994,43 @@
"properties": {
"name": {
"description": "Memory identifier (normalized to snake_case)",
"maxLength": 256,
"minLength": 1,
"title": "Name",
"type": "string"
},
"content": {
"description": "Memory content",
"maxLength": 65536,
"minLength": 1,
"title": "Content",
"type": "string"
},
"description": {
"default": "",
"description": "Short description for relevance matching",
"description": "Required non-empty description used for relevance matching",
"minLength": 1,
"title": "Description",
"type": "string"
},
"type": {
"default": "general",
"description": "Memory type",
"anyOf": [
{
"enum": [
"user",
"general",
"feedback",
"reference"
],
"title": "Type",
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Memory type; omission preserves it on update and defaults on insert",
"title": "Type"
},
"scope": {
"default": "global",
"description": "Memory scope",
@@ -3901,7 +4051,8 @@
},
"required": [
"name",
"content"
"content",
"description"
],
"title": "SaveMemoryRequest",
"type": "object"
@@ -3995,6 +4146,7 @@
"properties": {
"query": {
"description": "Search query text",
"minLength": 1,
"title": "Query",
"type": "string"
},
+8 -1
View File
@@ -394,7 +394,14 @@ export class TurnstoneServer extends BaseClient {
}
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(
+1 -1
View File
@@ -894,7 +894,7 @@ export interface WorkstreamsOptions {
export interface SaveMemoryRequest {
name: string;
content: string;
description?: string;
description: string;
type?: "user" | "general" | "feedback" | "reference";
scope?: "global" | "workstream" | "user";
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 () => {
const fetchFn = mockFetch({ status: "ok" });
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))
assert "error" in item, args
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"])
+1
View File
@@ -256,6 +256,7 @@ class TestWorldSeeding:
"memory": [
{
"name": "proj-context",
"description": "Project deployment context",
"content": "acme-api: staging tracks main.",
"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(
mid,
name,
kw.get("description", ""),
kw.get("description", "Seeded memory"),
kw.get("mem_type", "general"),
kw.get("scope", "global"),
kw.get("scope_id", ""),
@@ -130,6 +130,20 @@ def _seed_memory(storage, name="test_key", content="test content", **kw):
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
# ===========================================================================
@@ -158,6 +172,7 @@ class TestServerListMemories:
assert r.json()["memories"][0]["name"] == "a"
def test_filter_by_scope(self, server_client, storage):
_seed_workstream(storage)
_seed_memory(storage, "a", "x", scope="global")
_seed_memory(storage, "b", "y", 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")
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:
def test_create(self, server_client):
r = server_client.post(
"/v1/api/memories",
json={"name": "my_key", "content": "my content"},
json=_save_body("my_key", "my content"),
)
assert r.status_code == 201
data = r.json()
@@ -191,21 +232,23 @@ class TestServerSaveMemory:
def test_upsert(self, server_client):
server_client.post(
"/v1/api/memories",
json={"name": "key", "content": "v1"},
json=_save_body("key", "v1"),
)
r = server_client.post(
"/v1/api/memories",
json={"name": "key", "content": "v2"},
json=_save_body("key", "v2", description="Updated key description"),
)
assert r.status_code == 200
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(
"/v1/api/memories",
json={
"name": "feedback_key",
"content": "data",
"description": "Feedback memory",
"type": "feedback",
"scope": "workstream",
"scope_id": "ws1",
@@ -216,17 +259,30 @@ class TestServerSaveMemory:
assert r.json()["scope"] == "workstream"
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
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
@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):
r = server_client.post(
"/v1/api/memories",
json={"name": "k", "content": "c", "type": "bogus"},
json=_save_body("k", "c", type="bogus"),
)
assert r.status_code == 400
assert "invalid type" in r.json()["error"]
@@ -234,7 +290,7 @@ class TestServerSaveMemory:
def test_invalid_scope(self, server_client):
r = server_client.post(
"/v1/api/memories",
json={"name": "k", "content": "c", "scope": "bogus"},
json=_save_body("k", "c", scope="bogus"),
)
assert r.status_code == 400
assert "invalid scope" in r.json()["error"]
@@ -242,7 +298,7 @@ class TestServerSaveMemory:
def test_content_too_large(self, server_client):
r = server_client.post(
"/v1/api/memories",
json={"name": "k", "content": "x" * 70000},
json=_save_body("k", "x" * 70000),
)
assert r.status_code == 400
assert "limit" in r.json()["error"]
@@ -250,18 +306,32 @@ class TestServerSaveMemory:
def test_name_normalisation(self, server_client):
r = server_client.post(
"/v1/api/memories",
json={"name": "My-Key Name", "content": "data"},
json=_save_body("My-Key Name", "data"),
)
assert r.status_code == 201
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:
def test_user_scope_binds_to_auth(self, server_client):
"""User scope auto-resolves scope_id from authenticated user."""
r = server_client.post(
"/v1/api/memories",
json={"name": "priv", "content": "secret", "scope": "user"},
json=_save_body("priv", "secret", scope="user"),
)
assert r.status_code == 201
assert r.json()["scope_id"] == "test-user"
@@ -270,7 +340,7 @@ class TestServerUserScopeSecurity:
"""Cannot access another user's memories via scope_id."""
r = server_client.post(
"/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
@@ -278,7 +348,7 @@ class TestServerUserScopeSecurity:
"""Passing own user_id as scope_id is allowed."""
r = server_client.post(
"/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
@@ -298,7 +368,7 @@ class TestServerScopeScopeIdValidation:
def test_save_global_with_scope_id_rejected(self, server_client):
r = server_client.post(
"/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 "scope_id" in r.json()["error"]
@@ -306,15 +376,16 @@ class TestServerScopeScopeIdValidation:
def test_save_workstream_without_scope_id_rejected(self, server_client):
r = server_client.post(
"/v1/api/memories",
json={"name": "k", "content": "c", "scope": "workstream"},
json=_save_body("k", "c", scope="workstream"),
)
assert r.status_code == 400
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(
"/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
@@ -380,6 +451,21 @@ class TestServerSearchMemories:
r = server_client.post("/v1/api/memories/search", json={})
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:
def test_delete(self, server_client, storage):
@@ -393,6 +479,7 @@ class TestServerDeleteMemory:
assert r.status_code == 404
def test_delete_scoped(self, server_client, storage):
_seed_workstream(storage)
_seed_memory(storage, "k", "data", scope="workstream", scope_id="ws1")
# Wrong scope → not found
r = server_client.delete("/v1/api/memories/k")
@@ -405,6 +492,14 @@ class TestServerDeleteMemory:
r = server_client.delete("/v1/api/memories/k?scope=bogus")
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
@@ -492,6 +587,26 @@ class TestAdminDeleteMemory:
r = admin_client.delete("/v1/api/admin/memories/nonexistent-id")
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
+42 -6
View File
@@ -1,7 +1,9 @@
"""Tests for turnstone.core.memory_relevance — scoring, formatting, context extraction."""
from typing import Any
from unittest.mock import patch
from turnstone.core import auth
from turnstone.core.memory_relevance import (
MemoryConfig,
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:
"""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."""
from turnstone.core.memory import save_structured_memory
save_structured_memory("postgres_notes", "host=localhost port=5432")
save_structured_memory("redis_notes", "host=redis port=6379")
save_structured_memory("unrelated", "completely different")
save_structured_memory(
"postgres_notes", "host=localhost port=5432", description="Postgres notes"
)
save_structured_memory("redis_notes", "host=redis port=6379", description="Redis notes")
save_structured_memory("unrelated", "completely different", description="Unrelated notes")
session = _make_session()
item = session._prepare_memory(
@@ -532,12 +541,39 @@ class TestMemorySearchToolExecution:
# Sanity: prepare returned a search-ready dispatch (not an error item)
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 "postgres_notes" in msg
# Other memories don't match any query term
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:
"""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):
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()
with patch(
"turnstone.core.session.search_visible_structured_memories",
@@ -560,7 +596,7 @@ class TestPerTurnSearchCache:
def test_user_turn_invalidates_cache(self, tmp_db):
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()
with patch(
"turnstone.core.session.search_visible_structured_memories",
+357 -81
View File
@@ -1,11 +1,4 @@
"""Phase 4: the ``project`` memory scope.
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.
"""
"""Actor-scoped, live ``project`` memory authorization."""
from __future__ import annotations
@@ -35,10 +28,32 @@ def _session(**kwargs: Any) -> ChatSession:
return ChatSession(**defaults)
class TestConstructionResolvesProjectAccess:
"""Construction resolves the attached project through a single
``resolve_project_access`` call; recall is gated on read access AND a
non-archived project."""
def _execute_prepared_tool(
session: ChatSession,
item: dict[str, Any],
) -> 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:
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)
)
s = _session(user_id="u1", project_id="p1")
assert s._project_id == "p1"
assert s._project_writable is True
assert s._project_name == "P"
access = s._memory_access()
assert access.project_id == "p1"
assert access.project_writable is True
assert access.project_name == "P"
def test_read_only_member(self, monkeypatch: pytest.MonkeyPatch) -> None:
# 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)
)
s = _session(user_id="u1", project_id="p1")
assert s._project_id == "p1"
assert s._project_writable is False
access = s._memory_access()
assert access.project_id == "p1"
assert access.project_writable is False
def test_denied_without_access(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
auth, "resolve_project_access", lambda *a, **k: self._access(False, False)
)
s = _session(user_id="u1", project_id="p1")
assert s._project_id == ""
assert s._project_writable is False
access = s._memory_access()
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:
# 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")
)
s = _session(user_id="u1", project_id="p1")
assert s._project_id == ""
assert s._project_writable is False
access = s._memory_access()
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:
s = _session(user_id="u1")
assert s._project_id == ""
assert s._project_writable is False
access = s._memory_access()
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:
# 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)
)
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:
def test_interactive_visible_scopes_includes_project(self) -> None:
s = _session(user_id="u1", ws_id="ws1")
s._project_id = "p1"
def test_interactive_visible_scopes_includes_project(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
s = _project_session(monkeypatch)
scopes = s._visible_scopes()
assert ("project", "p1") in scopes
assert ("global", "") in scopes
@@ -107,9 +133,10 @@ class TestProjectRecall:
s = _session(user_id="u1", ws_id="ws1")
assert all(scope != "project" for scope, _ in s._visible_scopes())
def test_coordinator_adds_project_keeps_isolation(self) -> None:
s = _session(user_id="u1", kind=WorkstreamKind.COORDINATOR)
s._project_id = "p1"
def test_coordinator_adds_project_keeps_isolation(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
s = _project_session(monkeypatch, kind=WorkstreamKind.COORDINATOR)
scopes = s._visible_scopes()
assert ("coordinator", "u1") in scopes
assert ("project", "p1") in scopes
@@ -118,25 +145,24 @@ class TestProjectRecall:
def test_visible_scopes_omits_empty_project(self) -> None:
s = _session(user_id="u1", ws_id="ws1")
s._project_id = ""
assert all(scope != "project" for scope, _ in s._visible_scopes())
class TestProjectScopeResolutionAndValidation:
def test_resolve_scope_id_project(self) -> None:
s = _session(user_id="u1")
s._project_id = "p1"
def test_resolve_scope_id_project(self, monkeypatch: pytest.MonkeyPatch) -> None:
s = _project_session(monkeypatch)
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")
assert s._validate_scope("project", "cid") is not None # not attached → rejected
s._project_id = "p1"
assert s._validate_scope("project", "cid") is None
attached = _project_session(monkeypatch)
assert attached._validate_scope("project", "cid") is None
def test_coordinator_allows_project_rejects_global(self) -> None:
s = _session(user_id="u1", kind=WorkstreamKind.COORDINATOR)
s._project_id = "p1"
def test_coordinator_allows_project_rejects_global(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
s = _project_session(monkeypatch, kind=WorkstreamKind.COORDINATOR)
assert s._validate_scope("project", "cid") is None # project allowed for coord
assert s._validate_scope("global", "cid") is not None # global still rejected
@@ -172,78 +198,328 @@ class TestProjectInSystemContext:
class TestProjectWriteGate:
"""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
resolves ``_project_writable``; these drive the preparer to assert the gate
actually fires (the resolution-level check lives in
``TestConstructionResolvesProjectAccess``)."""
resolves live access; these drive the preparer to assert the gate actually
fires (the resolution-level check lives in ``TestLiveProjectAccess``)."""
def _attached(self, *, writable: bool) -> ChatSession:
s = _session(user_id="u1")
s._project_id = "p1"
s._project_writable = writable
return s
def _attached(self, monkeypatch: pytest.MonkeyPatch, *, writable: bool) -> ChatSession:
return _project_session(monkeypatch, writable=writable)
def test_save_blocked_when_read_only(self) -> None:
s = self._attached(writable=False)
def test_save_blocked_when_read_only(self, monkeypatch: pytest.MonkeyPatch) -> None:
s = self._attached(monkeypatch, writable=False)
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:
s = self._attached(writable=True)
def test_save_allowed_when_writable(self, monkeypatch: pytest.MonkeyPatch) -> None:
s = self._attached(monkeypatch, writable=True)
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 out.get("execute") is not None # would proceed to the save exec
def test_delete_blocked_when_read_only(self) -> None:
s = self._attached(writable=False)
def test_delete_blocked_when_read_only(self, monkeypatch: pytest.MonkeyPatch) -> None:
s = self._attached(monkeypatch, writable=False)
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:
s = self._attached(writable=True)
def test_delete_allowed_when_writable(self, monkeypatch: pytest.MonkeyPatch) -> None:
s = self._attached(monkeypatch, writable=True)
out = s._prepare_memory("cid", {"action": "delete", "scope": "project", "name": "k"})
assert "error" not in out
assert out.get("execute") is not None
class TestProjectDefaultSaveScope:
"""A writable attached project becomes the DEFAULT save scope (both kinds);
a read-only or unattached session keeps the kind default."""
class TestActingPrincipalProjectAuthority:
@staticmethod
def _tool_call(call_id: str, **arguments: Any) -> dict[str, Any]:
import json
def test_writable_project_is_default(self) -> None:
s = _session(user_id="u1")
s._project_id = "p1"
s._project_writable = True
return {
"id": call_id,
"function": {"name": "memory", "arguments": json.dumps(arguments)},
}
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"
def test_read_only_project_keeps_kind_default(self) -> None:
s = _session(user_id="u1")
s._project_id = "p1"
s._project_writable = False
assert s._default_memory_scope() == "global"
def test_read_only_project_remains_inherited_target(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
s = _project_session(monkeypatch, writable=False)
assert s._default_memory_scope() == "project"
def test_no_project_keeps_kind_default(self) -> None:
assert _session(user_id="u1")._default_memory_scope() == "global"
def test_coordinator_writable_project_is_default(self) -> None:
s = _session(user_id="u1", kind=WorkstreamKind.COORDINATOR)
s._project_id = "p1"
s._project_writable = True
def test_coordinator_writable_project_is_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
s = _project_session(monkeypatch, kind=WorkstreamKind.COORDINATOR)
assert s._default_memory_scope() == "project"
def test_coordinator_without_project_is_coordinator(self) -> None:
s = _session(user_id="u1", kind=WorkstreamKind.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
# scope=project / scope_id=project_id (not the global default).
s = _session(user_id="u1")
s._project_id = "p1"
s._project_writable = True
out = s._prepare_memory("cid", {"action": "save", "name": "k", "content": "v"})
s = _project_session(monkeypatch)
out = s._prepare_memory(
"cid",
{
"action": "save",
"name": "k",
"content": "v",
"description": "Test memory",
},
)
assert out.get("scope") == "project"
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.
backend.create_project("p1", "A", "u1")
backend.create_project("p2", "B", "u1")
backend.create_structured_memory("m1", "k", "", "general", "project", "p1", "v")
backend.create_structured_memory("m2", "k", "", "general", "project", "p2", "v")
backend.create_structured_memory("m3", "k", "", "general", "user", "u1", "v")
backend.create_structured_memory("m1", "k", "Test memory", "general", "project", "p1", "v")
backend.create_structured_memory("m2", "k", "Test memory", "general", "project", "p2", "v")
backend.create_structured_memory("m3", "k", "Test memory", "general", "user", "u1", "v")
assert backend.delete_project("p1")
assert backend.get_structured_memory("m1") is None # purged
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"
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
+396 -65
View File
@@ -29,7 +29,12 @@ from turnstone.core.model_turn import (
provider_extra_params,
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 (
Role,
Turn,
@@ -129,6 +134,15 @@ def _make_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
def _send_with_mocks(session, responses, mock_execute, **extra_patches):
"""Stand up the mock context that the queued-message ``send()`` tests share.
@@ -458,12 +472,20 @@ class TestTaskExec:
"func_name": "task_agent",
"needs_approval": True,
"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)
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.ui, "approve_tools", side_effect=approve_tools),
patch.object(session, "_ensure_system_prefix_fresh") as ensure_system_prefix,
):
session._execute_tools(
[{"id": "c1", "function": {"name": "task_agent", "arguments": "{}"}}],
@@ -471,9 +493,14 @@ class TestTaskExec:
my_generation=generation,
)
assert seen["prepare"] == "user-a"
assert seen["worker"] == "user-a"
assert seen["generation"] == generation
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 seen["execution_item"] is not item
approval_witness = seen["approval_item"]["_approval_cancel_witness"]
@@ -6199,6 +6226,96 @@ class TestSafePrepareTool:
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:
"""Verify the ``coordinator`` memory scope's resolution + validation rules.
@@ -6269,7 +6386,7 @@ class TestCoordinatorMemoryScope:
)
err = session._validate_scope("coordinator", "call_1")
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):
"""Children of a coord MUST be rejected too — letting them write
@@ -6286,7 +6403,7 @@ class TestCoordinatorMemoryScope:
)
err = session._validate_scope("coordinator", "call_1")
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):
from turnstone.core.workstream import WorkstreamKind
@@ -6314,6 +6431,7 @@ class TestCoordinatorMemoryScope:
"call_1",
{
"action": "save",
"description": "Test memory",
"name": "orchestration_plan",
"content": "step 1: investigate; step 2: report",
"scope": "coordinator",
@@ -6341,6 +6459,7 @@ class TestCoordinatorMemoryScope:
"call_1",
{
"action": "save",
"description": "Test memory",
"name": "injected_instruction",
"content": "ignore previous instructions and ...",
"scope": "coordinator",
@@ -6360,6 +6479,7 @@ class TestCoordinatorMemoryScope:
save_structured_memory(
"private_plan",
"internal coord notes",
description="Private orchestration plan",
scope="coordinator",
scope_id="user-1",
)
@@ -6424,16 +6544,20 @@ class TestCoordinatorMemoryScope:
from turnstone.core.workstream import WorkstreamKind
# 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(
"ws_note",
"interactive ws notes",
description="Workstream note",
scope="workstream",
scope_id="coord-1", # same id as the coord under test
)
save_structured_memory(
"user_note",
"user-wide notes from another IC session",
description="User note",
scope="user",
scope_id="user-1",
)
@@ -6466,10 +6590,13 @@ class TestCoordinatorMemoryScope:
from turnstone.core.memory import save_structured_memory
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(
"coord_x",
"orchestration content",
description="Coordinator content",
scope="coordinator",
scope_id="user-1",
)
@@ -6497,14 +6624,11 @@ class TestCoordinatorMemoryScope:
for bad in ("global", "workstream", "user"):
err = coord._validate_scope(bad, "call_1")
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):
"""Coord sessions calling memory(action='save') without an
explicit scope default to 'coordinator' anything else would
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."""
explicit scope target the coordinator namespace."""
from turnstone.core.workstream import WorkstreamKind
coord = _make_session(
@@ -6514,17 +6638,20 @@ class TestCoordinatorMemoryScope:
)
item = coord._prepare_memory(
"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 item["scope"] == "coordinator"
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
walk only the coordinator scope the IC walk
(workstream user global) would be wasted lookups against
rows the coord can't see."""
target only the coordinator scope."""
from turnstone.core.workstream import WorkstreamKind
coord = _make_session(
@@ -6539,10 +6666,8 @@ class TestCoordinatorMemoryScope:
assert "error" not in item
assert [s for s, _ in item["scopes_to_try"]] == ["coordinator"]
def test_ic_implicit_walk_unchanged(self, tmp_db):
"""Interactive sessions retain the narrowest-to-widest walk:
workstream user global. Coord scope is excluded IC
sessions can't see/write it anyway."""
def test_ic_unscoped_get_uses_single_global_target(self, tmp_db):
"""Without a project, interactive save/get/delete all inherit global."""
from turnstone.core.workstream import WorkstreamKind
ic = _make_session(
@@ -6555,8 +6680,7 @@ class TestCoordinatorMemoryScope:
{"action": "get", "name": "anything"},
)
assert "error" not in item
scopes = [s for s, _ in item["scopes_to_try"]]
assert scopes == ["workstream", "user", "global"]
assert item["scopes_to_try"] == [("global", "")]
def test_coord_memory_persists_across_sessions(self, tmp_db):
"""End-to-end through the real save lane: a memory saved by one
@@ -6575,13 +6699,14 @@ class TestCoordinatorMemoryScope:
"call_1",
{
"action": "save",
"description": "Test memory",
"name": "deploy_runbook",
"content": "drain node before rotating certs",
"scope": "coordinator",
},
)
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()
# Brand-new coordinator session, new ws_id, same user.
@@ -6595,7 +6720,7 @@ class TestCoordinatorMemoryScope:
{"action": "get", "name": "deploy_runbook"},
)
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
def test_coordinator_session_requires_user_id(self, tmp_db):
@@ -6636,11 +6761,17 @@ class TestCoordinatorMemoryScope:
coord._user_id = "" # simulate a constructor-bypassing double
err = coord._validate_scope("coordinator", "call_1")
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() == ""
item = coord._prepare_memory(
"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
@@ -6654,6 +6785,7 @@ class TestCoordinatorMemoryScope:
save_structured_memory(
"other_users_row",
"must not leak",
description="Another user's row",
scope="coordinator",
scope_id="user-9",
)
@@ -6682,7 +6814,48 @@ class TestMemoryToolAudit:
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")
item = session._prepare_memory(
"call_1",
@@ -6690,12 +6863,27 @@ class TestMemoryToolAudit:
"action": "save",
"name": "fact_one",
"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",
"type": "reference",
},
)
assert "error" not in item
session._exec_memory(item)
_execute_prepared_tool(session, item)
rows = self._audit_rows("memory.save")
assert len(rows) == 1
@@ -6712,6 +6900,71 @@ class TestMemoryToolAudit:
# The "create" path must NOT also stamp an update row.
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):
"""Global memories have no scope_id — the audit row's detail
must still carry the key (with value ``""``) so a forensic
@@ -6722,13 +6975,14 @@ class TestMemoryToolAudit:
"call_1",
{
"action": "save",
"description": "Test memory",
"name": "fact_global",
"content": "shared content",
"scope": "global",
},
)
assert "error" not in item
session._exec_memory(item)
_execute_prepared_tool(session, item)
rows = self._audit_rows("memory.save")
assert len(rows) == 1
@@ -6744,13 +6998,14 @@ class TestMemoryToolAudit:
"call_x",
{
"action": "save",
"description": "Test memory",
"name": "fact_one",
"content": content,
"scope": "user",
"type": "reference",
},
)
session._exec_memory(item)
_execute_prepared_tool(session, item)
saves = self._audit_rows("memory.save")
updates = self._audit_rows("memory.update")
@@ -6765,20 +7020,21 @@ class TestMemoryToolAudit:
"call_1",
{
"action": "save",
"description": "Test memory",
"name": "fact_one",
"content": "alpha",
"scope": "user",
"type": "reference",
},
)
session._exec_memory(save_item)
_execute_prepared_tool(session, save_item)
saved_memory_id = self._audit_rows("memory.save")[0]["resource_id"]
delete_item = session._prepare_memory(
"call_2",
{"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
rows = self._audit_rows("memory.delete")
@@ -6797,23 +7053,70 @@ class TestMemoryToolAudit:
"call_1",
{"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 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):
session = _make_session(ws_id="ws-1", user_id="user-1")
session._exec_memory(
session._prepare_memory(
save_item = session._prepare_memory(
"call_save",
{
"action": "save",
"description": "Test memory",
"name": "fact_one",
"content": "alpha",
"scope": "user",
},
)
)
_execute_prepared_tool(session, save_item)
for spec in (
{"action": "get", "name": "fact_one", "scope": "user"},
@@ -6822,7 +7125,7 @@ class TestMemoryToolAudit:
):
item = session._prepare_memory("call_read", spec)
assert "error" not in item
session._exec_memory(item)
_execute_prepared_tool(session, item)
# Only the save above should have audited.
save_count = len(self._audit_rows("memory.save"))
@@ -6842,6 +7145,7 @@ class TestMemoryToolAudit:
"call_1",
{
"action": "save",
"description": "Test memory",
"name": "fact_one",
"content": "alpha",
"scope": "user",
@@ -6851,7 +7155,7 @@ class TestMemoryToolAudit:
"turnstone.core.audit.record_audit",
side_effect=RuntimeError("audit storage exploded"),
):
_, msg = session._exec_memory(item)
_, msg = _execute_prepared_tool(session, item)
assert "Saved memory 'fact_one'" in msg
# The save itself still landed.
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.
Each kind sees only the tool surface it can actually use the
coord sees ``scope`` enum ``["coordinator"]`` and a coord-flavored
description; the IC sees ``["global", "workstream", "user"]`` and
the existing IC-flavored description. The union ``TOOLS`` list
keeps the full schema for introspection / docs / eval catalogs.
coord sees coordinator/project scopes and a coord-flavored description;
the IC sees global/workstream/user/project and the IC-flavored description.
The union ``TOOLS`` list keeps the full schema for introspection / docs /
eval catalogs.
"""
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
# 'project' scope, alongside its isolated 'coordinator' namespace.
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):
from turnstone.core.tools import COORDINATOR_TOOLS
@@ -6896,6 +7204,10 @@ class TestPerKindToolVariants:
scope = memory["function"]["parameters"]["properties"]["scope"]
# v1.7: 'project' is offered (usable when the workstream is attached).
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):
from turnstone.core.tools import INTERACTIVE_TOOLS
@@ -7024,7 +7336,12 @@ class TestMemoryAccessTouch:
def _save(name: str, content: str) -> None:
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
def _empty_session() -> ChatSession:
@@ -7134,7 +7451,7 @@ class TestMemoryAccessTouch:
self._save("kafka_runbook", "restart the kafka broker pods")
item = session._prepare_memory("call_1", {"action": "search", "query": "kafka"})
assert "error" not in item
session._exec_memory(item)
_execute_prepared_tool(session, item)
assert self._access_count("kafka_runbook") == 1
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"}
)
assert "error" not in item
session._exec_memory(item)
_execute_prepared_tool(session, item)
assert self._access_count("kafka_runbook") == 1
def test_get_miss_touches_nothing(self, tmp_db):
@@ -7153,7 +7470,7 @@ class TestMemoryAccessTouch:
item = session._prepare_memory(
"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
# The existing row must not be collaterally touched by a miss.
assert self._access_count("kafka_runbook") == 0
@@ -7162,7 +7479,7 @@ class TestMemoryAccessTouch:
session = self._empty_session()
self._save("kafka_runbook", "restart the kafka broker pods")
item = session._prepare_memory("call_1", {"action": "list"})
session._exec_memory(item)
_execute_prepared_tool(session, item)
assert self._access_count("kafka_runbook") == 0
def test_save_action_does_not_touch_access_count(self, tmp_db):
@@ -7174,9 +7491,15 @@ class TestMemoryAccessTouch:
session = self._empty_session()
item = session._prepare_memory(
"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
def test_save_through_exec_does_not_recompose_prefix(self, tmp_db):
@@ -7202,13 +7525,14 @@ class TestMemoryAccessTouch:
"call_1",
{
"action": "save",
"description": "Test memory",
"name": "kafka_scaling",
"content": "restart kafka and scale the broker pods cluster",
"scope": "global",
},
)
assert "error" not in item
session._exec_memory(item)
_execute_prepared_tool(session, item)
# 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")
@@ -7226,11 +7550,8 @@ class TestMemoryAccessTouch:
)
assert '<memory name="kafka_scaling"' in recomposed
def test_save_through_tool_preserves_omitted_overwrites_explicit(self, tmp_db):
"""The None-sentinel flows through _prepare_memory -> _exec_memory: a
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."""
def test_save_through_tool_requires_and_updates_description(self, tmp_db):
"""Every tool save describes the row; an omitted type stays preserved."""
from turnstone.core.memory import get_structured_memory_by_name
session = self._empty_session()
@@ -7246,48 +7567,58 @@ class TestMemoryAccessTouch:
},
)
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(
"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", "")
assert mem is not None
assert mem["content"] == "v2"
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,
# not silently downgraded to "general".
# Invalid/typo'd types fail preparation and do not mutate the row.
item_bad = session._prepare_memory(
"c2b",
{
"action": "save",
"description": "Test memory",
"name": "digest",
"content": "v2b",
"type": "nonsense",
"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", "")
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).
item3 = session._prepare_memory(
"c3",
{
"action": "save",
"description": "Test memory",
"name": "digest",
"content": "v3",
"type": "general",
"scope": "global",
},
)
session._exec_memory(item3)
_execute_prepared_tool(session, item3)
mem = get_structured_memory_by_name("digest", "global", "")
assert mem is not None
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.ws_id == "target-ws"
assert session._project_id == "target-project"
assert session._project_name == "Target Project"
assert session._project_writable is True
access = session._memory_access()
assert access.project_id == "target-project"
assert access.project_name == "Target Project"
assert access.project_writable is True
assert ("project", "target-project") in session._visible_scopes()
assert ("project", "source-project") not in session._visible_scopes()
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
import threading
from typing import Any
from unittest.mock import MagicMock, patch
@@ -1375,15 +1376,14 @@ class TestSkillCatalogDisclosure:
session._tools = []
session._client_type = ClientType.CLI
session._username = ""
# _init_system_messages renders the attached project into the Session
# Context; this __new__-built session skips __init__'s project resolution,
# so seed the (unattached) defaults it reads.
session._project_name = ""
session._project_id = ""
session._project_writable = False
# This __new__-built session skips __init__'s attachment setup.
session._memory_attached_project_id = ""
session._system_prefix_lock = threading.RLock()
session._system_prefix_dirty = True
session._system_prefix_signature = None
session._kind = "interactive"
# Persona snapshot attrs (set by __init__, bypassed here) — legacy
# defaults: no override, unrestricted tools, MCP + memory on.
# Persona snapshot attrs (set by __init__, bypassed here): open
# defaults with no override, unrestricted tools, MCP + memory on.
session._persona_name = ""
session._persona_prompt = ""
session._persona_tools = None
@@ -1393,6 +1393,7 @@ class TestSkillCatalogDisclosure:
session._memory_config = MagicMock()
session._memory_config.fetch_limit = 0
session._user_id = "test-user"
session._acting_user_id = ""
# _init_system_messages -> _recompute_shared_state reads the session
# owner (_mcp_user_id) to decide shared-workstream framing; __init__
# 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."""
import pytest
from turnstone.core.memory import (
count_structured_memories,
delete_structured_memory,
@@ -7,31 +9,42 @@ from turnstone.core.memory import (
list_structured_memories,
normalize_key,
save_structured_memory,
save_structured_memory_strict,
search_structured_memories,
)
def _save(name, content, **kwargs):
kwargs.setdefault("description", "test memory description")
return save_structured_memory(name, content, **kwargs)
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):
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 was_update is False
def test_save_upsert(self, tmp_db):
row1, was_update1 = save_structured_memory("test_key", "first")
row2, was_update2 = save_structured_memory("test_key", "second")
row1, was_update1 = _save("test_key", "first")
row2, was_update2 = _save("test_key", "second")
assert was_update1 is False
assert was_update2 is True
assert row2 and row1 and row2["memory_id"] == row1["memory_id"] # same row
assert row2["content"] == "second"
def test_save_normalizes_key(self, tmp_db):
save_structured_memory("My-Key", "value")
_save("My-Key", "value")
mems = list_structured_memories()
assert any(m["name"] == "my_key" for m in mems)
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")
assert len(mems) == 1
assert mems[0]["type"] == "user"
@@ -39,14 +52,14 @@ class TestSaveStructuredMemory:
class TestDeleteStructuredMemory:
def test_delete_existing(self, tmp_db):
save_structured_memory("mykey", "val")
_save("mykey", "val")
assert delete_structured_memory("mykey")
def test_delete_nonexistent(self, tmp_db):
assert not delete_structured_memory("nope")
def test_delete_normalizes_key(self, tmp_db):
save_structured_memory("my_key", "val")
_save("my_key", "val")
assert delete_structured_memory("My-Key")
@@ -55,25 +68,25 @@ class TestListStructuredMemories:
assert list_structured_memories() == []
def test_list_returns_saved(self, tmp_db):
save_structured_memory("a", "alpha")
save_structured_memory("b", "beta")
_save("a", "alpha")
_save("b", "beta")
mems = list_structured_memories()
assert len(mems) == 2
class TestSearchStructuredMemories:
def test_search_finds_match(self, tmp_db):
save_structured_memory("db_host", "localhost", description="database hostname")
save_structured_memory("api_url", "http://example.com")
_save("db_host", "localhost", description="database hostname")
_save("api_url", "http://example.com")
results = search_structured_memories("database")
assert len(results) >= 1
assert any(r["name"] == "db_host" for r in results)
def test_multiword_or_matches_partial(self, tmp_db):
"""OR-of-terms: memory matching only 1 of 3 query terms is returned."""
save_structured_memory("postgres_config", "host=localhost port=5432")
save_structured_memory("redis_config", "host=redis port=6379")
save_structured_memory("unrelated", "nothing relevant here")
_save("postgres_config", "host=localhost port=5432")
_save("redis_config", "host=redis port=6379")
_save("unrelated", "nothing relevant here")
# "postgres missing_word_a missing_word_b": only postgres_config matches "postgres"
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):
"""Multiple memories each matching different terms are all returned."""
save_structured_memory("key_alpha", "alpha content here")
save_structured_memory("key_beta", "beta content here")
save_structured_memory("key_other", "completely different")
_save("key_alpha", "alpha content here")
_save("key_beta", "beta content here")
_save("key_other", "completely different")
results = search_structured_memories("alpha beta")
names = {r["name"] for r in results}
@@ -95,9 +108,9 @@ class TestSearchStructuredMemories:
def test_search_scope_filtering_preserved(self, tmp_db):
"""Search with scope filter only returns memories in that scope."""
save_structured_memory("ws1_fact", "alpha info", scope="workstream", scope_id="ws1")
save_structured_memory("ws2_fact", "alpha info", scope="workstream", scope_id="ws2")
save_structured_memory("global_fact", "alpha info", scope="global")
_save("ws1_fact", "alpha info", scope="workstream", scope_id="ws1")
_save("ws2_fact", "alpha info", scope="workstream", scope_id="ws2")
_save("global_fact", "alpha info", scope="global")
results = search_structured_memories("alpha", scope="workstream", scope_id="ws1")
names = {r["name"] for r in results}
@@ -108,7 +121,7 @@ class TestSearchStructuredMemories:
class TestGetStructuredMemoryByName:
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", "")
assert mem is not None
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
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", "workstream", "ws1") is not None
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", "")
assert mem is not None
assert mem["name"] == "my_key"
@@ -134,8 +147,8 @@ class TestCountStructuredMemories:
assert count_structured_memories() == 0
def test_count_after_save(self, tmp_db):
save_structured_memory("a", "1")
save_structured_memory("b", "2")
_save("a", "1")
_save("b", "2")
assert count_structured_memories() == 2
@@ -154,11 +167,11 @@ class TestScopeIsolation:
def _seed(self):
"""Create memories across multiple scopes."""
save_structured_memory("global_note", "visible to all", scope="global")
save_structured_memory("ws1_note", "belongs to ws1", scope="workstream", scope_id="ws1")
save_structured_memory("ws2_note", "belongs to ws2", scope="workstream", scope_id="ws2")
save_structured_memory("u1_note", "belongs to user1", scope="user", scope_id="u1")
save_structured_memory("u2_note", "belongs to user2", scope="user", scope_id="u2")
_save("global_note", "visible to all", scope="global")
_save("ws1_note", "belongs to ws1", scope="workstream", scope_id="ws1")
_save("ws2_note", "belongs to ws2", scope="workstream", scope_id="ws2")
_save("u1_note", "belongs to user1", scope="user", scope_id="u1")
_save("u2_note", "belongs to user2", scope="user", scope_id="u2")
@staticmethod
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:
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):
backend.create_structured_memory("m1", "test_key", "desc", "general", "global", "", "data")
mem = backend.get_structured_memory("m1")
@@ -43,34 +52,44 @@ class TestSaveUpsert:
import pytest
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):
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):
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
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["memory_id"] == row1["memory_id"] # same row, not a duplicate
assert row2["content"] == "v2"
names = [r["name"] for r in backend.list_structured_memories(scope="global")]
assert names.count("upsert_key") == 1
def test_save_same_key_preserves_description_and_type_on_default_resave(self, backend):
from turnstone.core.memory import save_structured_memory
def test_save_same_key_requires_and_updates_description(self, backend):
from turnstone.core.memory import save_structured_memory, save_structured_memory_strict
save_structured_memory(
"meta_key", "c1", description="orig desc", mem_type="fact", scope="global"
)
# A re-save that omits description/type (defaults) must not clobber them.
save_structured_memory("meta_key", "c2", scope="global")
# Every update must describe the revised memory; type can still be omitted.
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", "")
assert row["content"] == "c2"
assert row["description"] == "orig desc"
assert row["description"] == "revised description"
assert row["type"] == "fact"
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")]
assert names.count("k") == 1
def test_upsert_none_preserves_explicit_overwrites(self, backend):
"""None description/type keep the stored value on conflict; an explicit
value (including "" / "general") overwrites it."""
def test_upsert_requires_description_and_preserves_omitted_type(self, backend):
"""Description is mandatory; an omitted type keeps the stored value."""
import pytest
backend.create_structured_memory("m1", "k", "keepdesc", "fact", "global", "", "v1")
# None -> preserve stored description/type (a content-only save).
row, _ = 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, 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["description"] == "keepdesc"
assert row["description"] == "new description"
assert row["type"] == "fact"
# Explicit "" / "general" -> overwrite.
row2, _ = backend.upsert_structured_memory("m3", "k", "", "general", "global", "", "v3")
assert row2["description"] == ""
row2, _ = backend.upsert_structured_memory(
"m3", "k", "final description", "general", "global", "", "v3"
)
assert row2["description"] == "final description"
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:
def test_delete_existing(self, backend):
@@ -118,63 +192,119 @@ class TestDelete:
assert not backend.delete_structured_memory("k", "global", "")
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:
def test_list_all(self, backend):
backend.create_structured_memory("m1", "a", "", "general", "global", "", "1")
backend.create_structured_memory("m2", "b", "", "user", "global", "", "2")
backend.create_structured_memory("m1", "a", "Test memory", "general", "global", "", "1")
backend.create_structured_memory("m2", "b", "Test memory", "user", "global", "", "2")
mems = backend.list_structured_memories()
assert len(mems) == 2
def test_list_by_type(self, backend):
backend.create_structured_memory("m1", "a", "", "general", "global", "", "1")
backend.create_structured_memory("m2", "b", "", "user", "global", "", "2")
backend.create_structured_memory("m1", "a", "Test memory", "general", "global", "", "1")
backend.create_structured_memory("m2", "b", "Test memory", "user", "global", "", "2")
mems = backend.list_structured_memories(mem_type="user")
assert len(mems) == 1
assert mems[0]["name"] == "b"
def test_list_by_scope(self, backend):
backend.create_structured_memory("m1", "a", "", "general", "global", "", "1")
backend.create_structured_memory("m2", "b", "", "general", "workstream", "ws1", "2")
backend.create_structured_memory("m1", "a", "Test memory", "general", "global", "", "1")
backend.create_structured_memory(
"m2", "b", "Test memory", "general", "workstream", "ws1", "2"
)
mems = backend.list_structured_memories(scope="workstream")
assert len(mems) == 1
def test_list_respects_limit(self, backend):
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)
assert len(mems) == 3
class TestSearch:
def test_search_by_name(self, backend):
backend.create_structured_memory("m1", "database_config", "", "general", "global", "", "pg")
backend.create_structured_memory("m2", "api_key", "", "general", "global", "", "secret")
backend.create_structured_memory(
"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")
assert len(results) == 1
assert results[0]["name"] == "database_config"
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")
assert len(results) == 1
def test_search_empty_lists_all(self, backend):
backend.create_structured_memory("m1", "a", "", "general", "global", "", "1")
backend.create_structured_memory("m2", "b", "", "general", "global", "", "2")
backend.create_structured_memory("m1", "a", "Test memory", "general", "global", "", "1")
backend.create_structured_memory("m2", "b", "Test memory", "general", "global", "", "2")
results = backend.search_structured_memories("")
assert len(results) == 2
class TestCount:
def test_count_all(self, backend):
backend.create_structured_memory("m1", "a", "", "general", "global", "", "1")
backend.create_structured_memory("m2", "b", "", "general", "global", "", "2")
backend.create_structured_memory("m1", "a", "Test memory", "general", "global", "", "1")
backend.create_structured_memory("m2", "b", "Test memory", "general", "global", "", "2")
assert backend.count_structured_memories() == 2
def test_count_by_scope(self, backend):
backend.create_structured_memory("m1", "a", "", "general", "global", "", "1")
backend.create_structured_memory("m2", "b", "", "general", "workstream", "ws1", "2")
backend.create_structured_memory("m1", "a", "Test memory", "general", "global", "", "1")
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="workstream") == 1
@@ -184,8 +314,12 @@ class TestSearchOrOfTerms:
def test_single_matching_term_in_multi_word_query(self, backend):
"""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("m2", "other_mem", "", "general", "global", "", "grape")
backend.create_structured_memory(
"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")
names = {r["name"] for r in results}
@@ -194,10 +328,18 @@ class TestSearchOrOfTerms:
def test_partial_overlap_across_memories(self, backend):
"""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("m2", "beta_doc", "", "general", "global", "", "beta")
backend.create_structured_memory("m3", "gamma_doc", "", "general", "global", "", "gamma")
backend.create_structured_memory("m4", "unrelated", "", "general", "global", "", "delta")
backend.create_structured_memory(
"m1", "alpha_doc", "Test memory", "general", "global", "", "alpha"
)
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")
names = {r["name"] for r in results}
@@ -209,12 +351,14 @@ class TestSearchOrOfTerms:
def test_scope_filter_preserved(self, backend):
"""OR-of-terms search still respects scope / scope_id filters."""
backend.create_structured_memory(
"m1", "ws1_note", "", "general", "workstream", "ws1", "info"
"m1", "ws1_note", "Test memory", "general", "workstream", "ws1", "info"
)
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")
names = {r["name"] for r in results}
@@ -224,9 +368,11 @@ class TestSearchOrOfTerms:
def test_term_cap_normalizes_unbounded_query(self, backend):
"""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(
"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
@@ -241,10 +387,18 @@ class TestVisibleStructuredMemories:
"""Single-query union helpers used by the composition path."""
def test_list_visible_unions_global_workstream_user(self, backend):
backend.create_structured_memory("m1", "g_note", "", "general", "global", "", "g")
backend.create_structured_memory("m2", "ws_note", "", "general", "workstream", "ws1", "w")
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(
"m1", "g_note", "Test memory", "general", "global", "", "g"
)
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")]
rows = backend.list_visible_structured_memories(scopes)
@@ -252,12 +406,14 @@ class TestVisibleStructuredMemories:
assert names == {"g_note", "ws_note", "u_note"} # ws2 excluded
def test_search_visible_unions_scopes_and_terms(self, backend):
backend.create_structured_memory("m1", "g_alpha", "", "general", "global", "", "alpha")
backend.create_structured_memory(
"m2", "ws_beta", "", "general", "workstream", "ws1", "beta"
"m1", "g_alpha", "Test memory", "general", "global", "", "alpha"
)
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")]
@@ -268,7 +424,9 @@ class TestVisibleStructuredMemories:
assert "ws_other" not in names # ws2 -> outside visibility
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.search_visible_structured_memories("x", []) == []
@@ -288,7 +446,7 @@ class TestStableOrderingOnTimestampTies:
# batch lands them in the same second.
for mid in ("zebra_id", "apple_id", "mango_id"):
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
+19 -5
View File
@@ -753,10 +753,20 @@ MemoryScope = Literal["global", "workstream", "user"]
class SaveMemoryRequest(BaseModel):
name: str = Field(description="Memory identifier (normalized to snake_case)")
content: str = Field(description="Memory content", max_length=65536)
description: str = Field(default="", description="Short description for relevance matching")
type: MemoryType = Field(default="general", description="Memory type")
name: str = Field(
description="Memory identifier (normalized to snake_case)",
min_length=1,
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_id: str = Field(
default="",
@@ -765,6 +775,8 @@ class SaveMemoryRequest(BaseModel):
@model_validator(mode="after")
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()
if self.scope == "global" and scope_id:
raise ValueError("scope_id is not allowed with global scope")
@@ -795,7 +807,7 @@ MemoryScopeFilter = Literal["", "global", "workstream", "user"]
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")
scope: MemoryScopeFilter = Field(default="", description="Filter by scope")
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")
if scope_id and not self.scope:
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
+7 -5
View File
@@ -496,16 +496,17 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
EndpointSpec(
"/v1/api/memories",
"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,
query_params=[
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(
"limit", "Max results (default 100, max 200)", schema_type="integer", default=100
),
],
error_codes=[400, 403, 404, 500],
tags=["Memories"],
),
EndpointSpec(
@@ -514,15 +515,16 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
"Save (upsert) a structured memory",
request_model=SaveMemoryRequest,
response_model=MemoryInfo,
error_codes=[400],
error_codes=[400, 403, 404, 500],
tags=["Memories"],
),
EndpointSpec(
"/v1/api/memories/search",
"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,
response_model=ListMemoriesResponse,
error_codes=[400, 403, 404, 500],
tags=["Memories"],
),
EndpointSpec(
@@ -534,7 +536,7 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
QueryParam("scope", "Scope (default: global)"),
QueryParam("scope_id", "Scope identifier"),
],
error_codes=[404],
error_codes=[400, 403, 404, 500],
tags=["Memories"],
),
# --- Admin settings ---
+5 -3
View File
@@ -9544,12 +9544,14 @@ async def admin_delete_memory(request: Request) -> JSONResponse:
return err
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:
return JSONResponse({"error": "Memory not found"}, status_code=404)
storage.delete_structured_memory_by_id(memory_id)
audit_uid, ip = _audit_context(request)
record_audit(
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(
name: str,
content: str,
description: str | None = None,
description: str,
mem_type: str | None = None,
scope: str = "global",
scope_id: str = "",
*,
require_active_project: bool = False,
) -> tuple[dict[str, str] | None, bool]:
"""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
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``
description / ``mem_type`` means "leave unset" -- the column default applies
on insert and the stored value is kept on conflict.
``description`` is required and must contain non-whitespace text for both
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:
row, was_update = get_storage().upsert_structured_memory(
str(uuid.uuid4()), name, description, mem_type, scope, scope_id, content
return save_structured_memory_strict(
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:
log.warning("Failed to save structured memory name=%s", name, exc_info=True)
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(
name: str, scope: str = "global", scope_id: str = ""
) -> dict[str, str] | None:
@@ -900,6 +942,13 @@ def get_structured_memory_by_name(
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:
"""Delete a structured memory by name+scope. Returns True if existed."""
name = normalize_key(name)
@@ -919,6 +968,36 @@ def delete_structured_memory_by_id(memory_id: str) -> bool:
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(
mem_type: 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,
content: str,
) -> 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")
with self._conn() as conn:
conn.execute(
@@ -4770,19 +4773,24 @@ class PostgreSQLBackend(_KeyedAttachmentSaveWrappers):
self,
memory_id: str,
name: str,
description: str | None,
description: str,
mem_type: str | None,
scope: str,
scope_id: str,
content: str,
*,
require_active_project: bool = False,
) -> tuple[dict[str, str], bool]:
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")
insert_stmt = pg_insert(structured_memories).values(
memory_id=memory_id,
name=name,
description="" if description is None else description,
description=description,
type="general" if mem_type is None else mem_type,
scope=scope,
scope_id=scope_id,
@@ -4792,15 +4800,13 @@ class PostgreSQLBackend(_KeyedAttachmentSaveWrappers):
last_accessed=now,
access_count=0,
)
# On conflict, refresh content + timestamps. description/type are
# overwritten only when the caller supplied them; None means "unset" ->
# keep the stored value. created and access_count are left untouched.
# On conflict, refresh content, description, and timestamps. A None type means
# "unset" -> keep the stored value. created/access_count stay untouched.
set_: dict[str, Any] = {
"content": insert_stmt.excluded.content,
"updated": now,
"last_accessed": now,
}
if description is not None:
set_["description"] = insert_stmt.excluded.description
if mem_type is not None:
set_["type"] = insert_stmt.excluded.type
@@ -4809,6 +4815,22 @@ class PostgreSQLBackend(_KeyedAttachmentSaveWrappers):
set_=set_,
).returning(structured_memories)
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()
conn.commit()
if row is None: # unreachable: ON CONFLICT DO UPDATE returns one row
@@ -4841,26 +4863,58 @@ class PostgreSQLBackend(_KeyedAttachmentSaveWrappers):
def delete_structured_memory(
self, name: str, scope: str = "global", scope_id: str = ""
) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.delete(structured_memories).where(
return self.delete_structured_memory_returning(name, scope, scope_id) is not None
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_(
structured_memories.c.name == name,
structured_memories.c.scope == scope,
structured_memories.c.scope_id == scope_id,
)
)
.returning(structured_memories)
)
with self._conn() as conn:
row = conn.execute(stmt).fetchone()
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:
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:
result = conn.execute(
sa.delete(structured_memories).where(structured_memories.c.memory_id == memory_id)
)
row = conn.execute(
sa.delete(structured_memories)
.where(structured_memories.c.memory_id == memory_id)
.returning(structured_memories)
).fetchone()
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(
self,
@@ -5905,6 +5959,17 @@ class PostgreSQLBackend(_KeyedAttachmentSaveWrappers):
def delete_project(self, project_id: str) -> bool:
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
# memory + member rows explicitly (same transaction) before the
# project row — honouring the "destroys the container AND its scoped
+31 -6
View File
@@ -829,34 +829,41 @@ class StorageBackend(Protocol):
scope_id: str,
content: str,
) -> None:
"""Create a structured memory record."""
"""Create a structured memory record with a non-empty description."""
...
def upsert_structured_memory(
self,
memory_id: str,
name: str,
description: str | None,
description: str,
mem_type: str | None,
scope: str,
scope_id: str,
content: str,
*,
require_active_project: bool = False,
) -> tuple[dict[str, str], bool]:
"""Insert a structured memory, or update it in place on a
``(name, scope, scope_id)`` conflict.
Atomic ``INSERT ... ON CONFLICT DO UPDATE ... RETURNING`` no
IntegrityError round-trip, race-safe under concurrent saves of the same
key. ``description`` / ``mem_type`` of ``None`` mean "unset": the
column default ("" / "general") is used on insert and the stored value
is kept on conflict; a non-``None`` value (including "" or "general") is
written.
key. ``description`` must contain non-whitespace text on every insert
or update. A ``mem_type`` of ``None`` means "unset": the column default
is used on insert and the stored value is kept on conflict.
Returns ``(row, was_update)`` (like Django's ``update_or_create``): the
full saved row, and ``True`` when an existing row was updated rather
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
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."""
...
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:
"""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(
self,
mem_type: str = "",
+73 -14
View File
@@ -4818,6 +4818,9 @@ class SQLiteBackend(_KeyedAttachmentSaveWrappers):
scope_id: str,
content: str,
) -> 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")
with self._conn() as conn:
conn.execute(
@@ -4842,19 +4845,24 @@ class SQLiteBackend(_KeyedAttachmentSaveWrappers):
self,
memory_id: str,
name: str,
description: str | None,
description: str,
mem_type: str | None,
scope: str,
scope_id: str,
content: str,
*,
require_active_project: bool = False,
) -> tuple[dict[str, str], bool]:
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")
insert_stmt = sqlite_insert(structured_memories).values(
memory_id=memory_id,
name=name,
description="" if description is None else description,
description=description,
type="general" if mem_type is None else mem_type,
scope=scope,
scope_id=scope_id,
@@ -4864,15 +4872,13 @@ class SQLiteBackend(_KeyedAttachmentSaveWrappers):
last_accessed=now,
access_count=0,
)
# On conflict, refresh content + timestamps. description/type are
# overwritten only when the caller supplied them; None means "unset" ->
# keep the stored value. created and access_count are left untouched.
# On conflict, refresh content, description, and timestamps. A None type means
# "unset" -> keep the stored value. created/access_count stay untouched.
set_: dict[str, Any] = {
"content": insert_stmt.excluded.content,
"updated": now,
"last_accessed": now,
}
if description is not None:
set_["description"] = insert_stmt.excluded.description
if mem_type is not None:
set_["type"] = insert_stmt.excluded.type
@@ -4881,6 +4887,24 @@ class SQLiteBackend(_KeyedAttachmentSaveWrappers):
set_=set_,
).returning(structured_memories)
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()
conn.commit()
if row is None: # unreachable: ON CONFLICT DO UPDATE returns one row
@@ -4913,26 +4937,58 @@ class SQLiteBackend(_KeyedAttachmentSaveWrappers):
def delete_structured_memory(
self, name: str, scope: str = "global", scope_id: str = ""
) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.delete(structured_memories).where(
return self.delete_structured_memory_returning(name, scope, scope_id) is not None
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_(
structured_memories.c.name == name,
structured_memories.c.scope == scope,
structured_memories.c.scope_id == scope_id,
)
)
.returning(structured_memories)
)
with self._conn() as conn:
row = conn.execute(stmt).fetchone()
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:
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:
result = conn.execute(
sa.delete(structured_memories).where(structured_memories.c.memory_id == memory_id)
)
row = conn.execute(
sa.delete(structured_memories)
.where(structured_memories.c.memory_id == memory_id)
.returning(structured_memories)
).fetchone()
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(
self,
@@ -5961,6 +6017,9 @@ class SQLiteBackend(_KeyedAttachmentSaveWrappers):
def delete_project(self, project_id: str) -> bool:
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
# memory + member rows explicitly (same transaction) before the
# 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.
Each kind sees only the surface it can actually use for ``memory``,
coord sessions get a description + scope enum that mention only the
``coordinator`` scope, while interactive sessions get a description
+ scope enum that omit ``coordinator`` entirely. This keeps the
LLM contract tight: the model never sees enum values it can't use,
and never reads description sentences explaining why a scope is
forbidden.
coord sessions get ``coordinator`` plus the attach-dependent ``project``
scope, while interactive sessions get global/workstream/user plus
``project``. This keeps the LLM contract tight: the model never sees enum
values it can't use, and never reads description sentences explaining why
a scope is forbidden.
No-op (returns the input tool unchanged) when the tool has no
``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
# tool's own schema (:func:`_tasks_action_enum`) and the READ half is
# ``ChatSession._TASKS_READ_ACTIONS``, production's own classifier
# the one its parallel-batch guard and approval path rule on. Mutating
# is the remainder, so an action added to the schema counts as a
# ``_TASKS_READ_ACTIONS``, production's own preparer classifier.
# Mutating 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
# never be silently dropped from the bookkeeping test, and the drift
# 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(
row["name"],
row["content"],
row.get("description"),
row["description"],
row.get("type"),
scope=row.get("scope", "global"),
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
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
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
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", ())):
if not isinstance(row, 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)
if not isinstance(v, str) or not v.strip():
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": [
{
"name": "acme-api-project",
"description": "Acme API repository and deployment context",
"type": "reference",
"content": (
"acme-api: FastAPI service. Repo layout: "
@@ -105,6 +106,7 @@ NUDGE_CELLS: list[dict[str, Any]] = [
},
{
"name": "auth-backend-migration-status",
"description": "Current authentication migration status",
"content": (
"migrations/007_auth_backend.sql applied on the "
"staging replica; auth service suite green "
+6 -4
View File
@@ -525,19 +525,21 @@ class AsyncTurnstoneServer(_BaseClient):
name: str,
content: str,
*,
description: str = "",
description: str,
mem_type: str = "general",
scope: str = "global",
scope_id: str = "",
) -> MemoryInfo:
description = (description or "").strip()
if not description:
raise ValueError("memory description is required and must be non-empty")
body: dict[str, Any] = {
"name": name,
"content": content,
"description": description,
"type": mem_type,
"scope": scope,
}
if description:
body["description"] = description
if scope_id:
body["scope_id"] = scope_id
return await self._request(
@@ -868,7 +870,7 @@ class TurnstoneServer:
name: str,
content: str,
*,
description: str = "",
description: str,
mem_type: str = "general",
scope: str = "global",
scope_id: str = "",
+211 -49
View File
@@ -3495,31 +3495,157 @@ def _resolve_user_scope_id(
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:
"""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_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:
limit = min(int(request.query_params.get("limit", "100")), 200)
limit = int(request.query_params.get("limit", "100"))
except (ValueError, TypeError):
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:
return err
if scope == "user":
scope_id, err = _resolve_user_scope_id(request, scope_id)
if err:
return err
rows = list_structured_memories(mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit)
try:
storage = get_storage()
if scope:
rows = storage.list_structured_memories(
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)})
async def save_memory(request: Request) -> JSONResponse:
"""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
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"},
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")
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")
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_id = str(body.get("scope_id", ""))
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)}"},
status_code=400,
)
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, scope_id, err = _resolve_rest_memory_scope(
request,
scope,
scope_id,
allow_empty=False,
)
if scope == "user":
scope_id, err = _resolve_user_scope_id(request, scope_id)
if err:
return err
err = _validate_scope_scope_id(scope, scope_id, require_scope_id=True)
if err:
return err
# The upsert RETURNINGs the full saved row, so no follow-up read is needed.
row, was_update = save_structured_memory(
name, content, description=description, mem_type=mem_type, scope=scope, scope_id=scope_id
try:
row, was_update = save_structured_memory_strict(
name,
content,
description=description,
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)
_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)
@@ -3575,7 +3711,7 @@ async def search_memories(request: Request) -> JSONResponse:
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
body = await read_json_or_400(request)
@@ -3584,46 +3720,72 @@ async def search_memories(request: Request) -> JSONResponse:
query = str(body.get("query", "")).strip()
if not query:
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_id = str(body.get("scope_id", ""))
try:
limit = min(int(body.get("limit", 20)), 50)
limit = int(body.get("limit", 20))
except (ValueError, TypeError):
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:
return err
if scope == "user":
scope_id, err = _resolve_user_scope_id(request, scope_id)
if err:
return err
rows = search_fn(query, mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit)
try:
storage = get_storage()
if scope:
rows = storage.search_structured_memories(
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)})
async def delete_memory_endpoint(request: Request) -> JSONResponse:
"""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"])
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", "")
if scope == "user":
scope_id, err = _resolve_user_scope_id(request, scope_id)
scope, scope_id, err = _resolve_rest_memory_scope(
request,
scope,
scope_id,
allow_empty=False,
)
if err:
return err
err = _validate_scope_scope_id(scope, scope_id, require_scope_id=True)
if err:
return err
if delete_structured_memory(name, scope, scope_id):
return JSONResponse({"status": "ok", "name": name})
try:
deleted = delete_structured_memory_returning_strict(name, scope, scope_id)
except Exception:
log.warning("memory.rest_delete_failed", name=name, exc_info=True)
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)
_audit_rest_memory_mutation(request, "memory.delete", deleted)
return JSONResponse({"status": "ok", "name": name})
# ---------------------------------------------------------------------------
+9 -7
View File
@@ -1,6 +1,6 @@
{
"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": {
"type": "object",
"properties": {
@@ -11,6 +11,7 @@
},
"name": {
"type": "string",
"maxLength": 256,
"description": "Memory identifier (required for 'save', 'get', and 'delete'). Short snake_case key."
},
"content": {
@@ -19,7 +20,8 @@
},
"description": {
"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": "string",
@@ -29,7 +31,7 @@
"scope": {
"type": "string",
"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": {
"type": "string",
@@ -46,20 +48,20 @@
"interactive": true,
"kind_variants": {
"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": {
"scope": {
"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": {
"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": {
"scope": {
"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."
}
}
}