molecule-core/docs/api-protocol/memory-plugin-v1.yaml
Hongming Wang 1e97fb9a16 Memory v2 fixup C1: backfill idempotency via MemoryWrite.id
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.
2026-05-04 08:54:13 -07:00

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