Self-review (post-merge) flagged that the backfill claimed to be
idempotent on re-run but actually duplicates every row because the
plugin's INSERT uses gen_random_uuid() and ignores any id passed in.
Fix is contract-level: extend MemoryWrite with an optional `id`
idempotency key. When supplied, the plugin MUST treat the write as
upsert keyed on this id; when omitted, the plugin generates a fresh
UUID (production agent commits keep working unchanged).
Changes:
* docs/api-protocol/memory-plugin-v1.yaml: add id field with
description that flags it as idempotency key
* internal/memory/contract/contract.go: add ID to MemoryWrite struct,
update memory_write_minimal golden vector
* internal/memory/pgplugin/store.go: split CommitMemory into two
paths — upsert when body.ID set (INSERT ... ON CONFLICT (id) DO
UPDATE), plain INSERT otherwise
* cmd/memory-backfill/main.go: pass agent_memories.id to MemoryWrite,
fix the false comment about 409 deduplication
New tests:
* pgplugin: TestCommitMemory_WithIDUpserts pins the upsert SQL is
used when id is set; TestCommitMemory_UpsertScanError covers the
error branch
* backfill: TestBackfill_PassesSourceUUIDAsIdempotencyKey pins the
forwarding behavior; TestBackfill_RerunIsIdempotent simulates a
retry and asserts both runs pass the same uuid (plugin upsert is
what makes this safe)
Why this matters: operators retrying a failed backfill (which they
will — networks fail, transactions abort) would otherwise create N
duplicates per memory. The duplicates aren't visible until search
results show obvious dupes — debugging that under prod load is bad.
Production agent commits are unaffected: they leave id empty, the
plugin generates a fresh UUID via gen_random_uuid(), zero behavior
change for the hot path.
359 lines
11 KiB
YAML
359 lines
11 KiB
YAML
openapi: 3.0.3
|
|
info:
|
|
title: Molecule Memory Plugin v1
|
|
version: 1.0.0
|
|
description: |
|
|
Contract between workspace-server and a memory backend plugin. The
|
|
plugin owns its own storage; workspace-server is the security
|
|
perimeter (secret redaction, namespace ACL, GLOBAL audit/wrap).
|
|
|
|
Defined in RFC #2728. See docs/rfc/memory-v2-rationale.md for design
|
|
rationale.
|
|
|
|
Auth: none. Plugins MUST be reachable only on a private network or
|
|
unix socket — workspace-server is the only sanctioned client.
|
|
servers:
|
|
- url: http://localhost:9100
|
|
description: Built-in postgres-backed plugin (default)
|
|
|
|
paths:
|
|
/v1/health:
|
|
get:
|
|
summary: Liveness + capability probe
|
|
operationId: getHealth
|
|
responses:
|
|
'200':
|
|
description: Plugin healthy
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/HealthResponse' }
|
|
'503':
|
|
description: Plugin unhealthy (e.g., backing store down)
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/v1/namespaces/{name}:
|
|
parameters:
|
|
- $ref: '#/components/parameters/NamespaceName'
|
|
put:
|
|
summary: Upsert a namespace (idempotent)
|
|
operationId: upsertNamespace
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/NamespaceUpsert' }
|
|
responses:
|
|
'200': { $ref: '#/components/responses/Namespace' }
|
|
'400': { $ref: '#/components/responses/BadRequest' }
|
|
patch:
|
|
summary: Update namespace metadata or TTL
|
|
operationId: patchNamespace
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/NamespacePatch' }
|
|
responses:
|
|
'200': { $ref: '#/components/responses/Namespace' }
|
|
'404': { $ref: '#/components/responses/NotFound' }
|
|
delete:
|
|
summary: Delete namespace and all its memories (operator action)
|
|
operationId: deleteNamespace
|
|
responses:
|
|
'204':
|
|
description: Deleted
|
|
'404': { $ref: '#/components/responses/NotFound' }
|
|
|
|
/v1/namespaces/{name}/memories:
|
|
parameters:
|
|
- $ref: '#/components/parameters/NamespaceName'
|
|
post:
|
|
summary: Write a memory to a namespace
|
|
description: |
|
|
`content` MUST already be secret-redacted by the workspace-server.
|
|
Plugin does not run additional redaction.
|
|
operationId: commitMemory
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/MemoryWrite' }
|
|
responses:
|
|
'201':
|
|
description: Memory persisted
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/MemoryWriteResponse' }
|
|
'400': { $ref: '#/components/responses/BadRequest' }
|
|
'404': { $ref: '#/components/responses/NotFound' }
|
|
|
|
/v1/search:
|
|
post:
|
|
summary: Search memories across one or more namespaces
|
|
description: |
|
|
workspace-server MUST intersect the requested `namespaces` with
|
|
the caller's currently-readable set BEFORE invoking this
|
|
endpoint. The plugin treats the list as authoritative.
|
|
operationId: searchMemories
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/SearchRequest' }
|
|
responses:
|
|
'200':
|
|
description: Search results
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/SearchResponse' }
|
|
'400': { $ref: '#/components/responses/BadRequest' }
|
|
|
|
/v1/memories/{id}:
|
|
parameters:
|
|
- in: path
|
|
name: id
|
|
required: true
|
|
schema: { type: string, format: uuid }
|
|
delete:
|
|
summary: Forget a memory by id
|
|
description: |
|
|
`requested_by_namespace` is the namespace the caller has write
|
|
access to; the plugin SHOULD reject if the memory doesn't belong
|
|
to that namespace.
|
|
operationId: forgetMemory
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/ForgetRequest' }
|
|
responses:
|
|
'204':
|
|
description: Forgotten
|
|
'403': { $ref: '#/components/responses/Forbidden' }
|
|
'404': { $ref: '#/components/responses/NotFound' }
|
|
|
|
components:
|
|
parameters:
|
|
NamespaceName:
|
|
in: path
|
|
name: name
|
|
required: true
|
|
schema:
|
|
type: string
|
|
minLength: 1
|
|
maxLength: 256
|
|
pattern: '^[a-z]+:[A-Za-z0-9_:.\-]+$'
|
|
example: 'workspace:550e8400-e29b-41d4-a716-446655440000'
|
|
|
|
responses:
|
|
Namespace:
|
|
description: Namespace state
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Namespace' }
|
|
BadRequest:
|
|
description: Invalid input
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
NotFound:
|
|
description: Resource not found
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
Forbidden:
|
|
description: Caller lacks write access to the requested namespace
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
schemas:
|
|
HealthResponse:
|
|
type: object
|
|
required: [status, version, capabilities]
|
|
properties:
|
|
status: { type: string, enum: [ok, degraded] }
|
|
version: { type: string, example: "1.0.0" }
|
|
capabilities:
|
|
type: array
|
|
items:
|
|
type: string
|
|
enum: [embedding, fts, ttl, pin, propagation]
|
|
description: |
|
|
Optional features this plugin supports. workspace-server
|
|
adapts MCP responses based on this list (e.g., agents can
|
|
request semantic search only when `embedding` is present).
|
|
|
|
NamespaceKind:
|
|
type: string
|
|
enum: [workspace, team, org, custom]
|
|
|
|
Namespace:
|
|
type: object
|
|
required: [name, kind, created_at]
|
|
properties:
|
|
name: { type: string }
|
|
kind: { $ref: '#/components/schemas/NamespaceKind' }
|
|
expires_at:
|
|
type: string
|
|
format: date-time
|
|
nullable: true
|
|
metadata:
|
|
type: object
|
|
additionalProperties: true
|
|
nullable: true
|
|
created_at: { type: string, format: date-time }
|
|
|
|
NamespaceUpsert:
|
|
type: object
|
|
required: [kind]
|
|
properties:
|
|
kind: { $ref: '#/components/schemas/NamespaceKind' }
|
|
expires_at: { type: string, format: date-time, nullable: true }
|
|
metadata:
|
|
type: object
|
|
additionalProperties: true
|
|
nullable: true
|
|
|
|
NamespacePatch:
|
|
type: object
|
|
properties:
|
|
expires_at: { type: string, format: date-time, nullable: true }
|
|
metadata:
|
|
type: object
|
|
additionalProperties: true
|
|
nullable: true
|
|
|
|
MemoryKind:
|
|
type: string
|
|
enum: [fact, summary, checkpoint]
|
|
|
|
MemorySource:
|
|
type: string
|
|
enum: [agent, runtime, user]
|
|
|
|
MemoryWrite:
|
|
type: object
|
|
required: [content, kind, source]
|
|
properties:
|
|
id:
|
|
type: string
|
|
format: uuid
|
|
nullable: true
|
|
description: |
|
|
Optional idempotency key. When supplied, the plugin MUST
|
|
treat the write as upsert keyed on this id (re-running
|
|
the same write does not duplicate). When omitted, the
|
|
plugin generates a fresh UUID. Used by the backfill CLI.
|
|
content:
|
|
type: string
|
|
minLength: 1
|
|
description: Already secret-redacted by workspace-server.
|
|
kind: { $ref: '#/components/schemas/MemoryKind' }
|
|
source: { $ref: '#/components/schemas/MemorySource' }
|
|
expires_at: { type: string, format: date-time, nullable: true }
|
|
propagation:
|
|
type: object
|
|
additionalProperties: true
|
|
nullable: true
|
|
description: |
|
|
Opaque metadata the plugin stores and returns. Reserved for
|
|
future cross-namespace propagation semantics.
|
|
pin: { type: boolean, default: false }
|
|
embedding:
|
|
type: array
|
|
items: { type: number }
|
|
nullable: true
|
|
description: |
|
|
Optional pre-computed embedding. Plugins reporting the
|
|
`embedding` capability MAY ignore this and recompute.
|
|
|
|
MemoryWriteResponse:
|
|
type: object
|
|
required: [id, namespace]
|
|
properties:
|
|
id: { type: string, format: uuid }
|
|
namespace: { type: string }
|
|
|
|
Memory:
|
|
type: object
|
|
required: [id, namespace, content, kind, source, created_at]
|
|
properties:
|
|
id: { type: string, format: uuid }
|
|
namespace: { type: string }
|
|
content: { type: string }
|
|
kind: { $ref: '#/components/schemas/MemoryKind' }
|
|
source: { $ref: '#/components/schemas/MemorySource' }
|
|
expires_at: { type: string, format: date-time, nullable: true }
|
|
propagation:
|
|
type: object
|
|
additionalProperties: true
|
|
nullable: true
|
|
pin: { type: boolean }
|
|
created_at: { type: string, format: date-time }
|
|
score:
|
|
type: number
|
|
nullable: true
|
|
description: Relevance score from search (semantic + FTS).
|
|
|
|
SearchRequest:
|
|
type: object
|
|
required: [namespaces]
|
|
properties:
|
|
namespaces:
|
|
type: array
|
|
items: { type: string }
|
|
minItems: 1
|
|
description: |
|
|
Already intersected with the caller's readable set by
|
|
workspace-server.
|
|
query: { type: string }
|
|
kinds:
|
|
type: array
|
|
items: { $ref: '#/components/schemas/MemoryKind' }
|
|
limit:
|
|
type: integer
|
|
minimum: 1
|
|
maximum: 100
|
|
default: 20
|
|
embedding:
|
|
type: array
|
|
items: { type: number }
|
|
nullable: true
|
|
|
|
SearchResponse:
|
|
type: object
|
|
required: [memories]
|
|
properties:
|
|
memories:
|
|
type: array
|
|
items: { $ref: '#/components/schemas/Memory' }
|
|
|
|
ForgetRequest:
|
|
type: object
|
|
required: [requested_by_namespace]
|
|
properties:
|
|
requested_by_namespace:
|
|
type: string
|
|
description: Namespace the caller has write access to.
|
|
|
|
Error:
|
|
type: object
|
|
required: [code, message]
|
|
properties:
|
|
code:
|
|
type: string
|
|
enum:
|
|
- bad_request
|
|
- not_found
|
|
- forbidden
|
|
- internal
|
|
- unavailable
|
|
message: { type: string }
|
|
details:
|
|
type: object
|
|
additionalProperties: true
|
|
nullable: true
|