diff --git a/docs/api-protocol/memory-plugin-v1.yaml b/docs/api-protocol/memory-plugin-v1.yaml new file mode 100644 index 00000000..92c8842b --- /dev/null +++ b/docs/api-protocol/memory-plugin-v1.yaml @@ -0,0 +1,349 @@ +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: + 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 diff --git a/workspace-server/internal/memory/contract/contract.go b/workspace-server/internal/memory/contract/contract.go new file mode 100644 index 00000000..2e913159 --- /dev/null +++ b/workspace-server/internal/memory/contract/contract.go @@ -0,0 +1,319 @@ +// Package contract holds the typed Go bindings for the Memory Plugin v1 +// HTTP contract defined at docs/api-protocol/memory-plugin-v1.yaml. +// +// These types are the wire shape between workspace-server (the only +// sanctioned client) and any memory plugin implementation. They are +// kept in their own package so the plugin client (PR-2) and the +// built-in postgres plugin server (PR-3) share a single source of +// truth for JSON tags and validation rules. +// +// Validation lives next to the types via the Validate() methods so +// every wire object self-checks; PR-2's HTTP client and PR-3's HTTP +// server both call Validate() at the boundary. +package contract + +import ( + "errors" + "fmt" + "regexp" + "strings" + "time" +) + +// SchemaVersion pins the contract revision the workspace-server expects +// from /v1/health responses. Bump in lockstep with the OpenAPI spec. +const SchemaVersion = "1.0.0" + +// Capability strings reported by /v1/health. Plugins MAY report any +// subset; workspace-server gates feature exposure on what's reported. +const ( + CapabilityEmbedding = "embedding" + CapabilityFTS = "fts" + CapabilityTTL = "ttl" + CapabilityPin = "pin" + CapabilityPropagation = "propagation" +) + +// NamespaceKind enumerates the four namespace shapes workspace-server +// derives from the team tree. `custom` is reserved for operator-defined +// cross-workspace channels. +type NamespaceKind string + +const ( + NamespaceKindWorkspace NamespaceKind = "workspace" + NamespaceKindTeam NamespaceKind = "team" + NamespaceKindOrg NamespaceKind = "org" + NamespaceKindCustom NamespaceKind = "custom" +) + +// MemoryKind distinguishes facts (point-in-time observations), summaries +// (compressed multi-fact rollups), and checkpoints (durable state +// markers between sessions). +type MemoryKind string + +const ( + MemoryKindFact MemoryKind = "fact" + MemoryKindSummary MemoryKind = "summary" + MemoryKindCheckpoint MemoryKind = "checkpoint" +) + +// MemorySource records who wrote a memory: the agent itself, the +// workspace runtime (e.g., end-of-session auto-summary), or the user +// (canvas-side input). +type MemorySource string + +const ( + MemorySourceAgent MemorySource = "agent" + MemorySourceRuntime MemorySource = "runtime" + MemorySourceUser MemorySource = "user" +) + +// ErrorCode enumerates the wire error codes plugins return. +type ErrorCode string + +const ( + ErrorCodeBadRequest ErrorCode = "bad_request" + ErrorCodeNotFound ErrorCode = "not_found" + ErrorCodeForbidden ErrorCode = "forbidden" + ErrorCodeInternal ErrorCode = "internal" + ErrorCodeUnavailable ErrorCode = "unavailable" +) + +// HealthResponse is the body of GET /v1/health. +type HealthResponse struct { + Status string `json:"status"` + Version string `json:"version"` + Capabilities []string `json:"capabilities"` +} + +// HasCapability reports whether the plugin advertises the named +// capability. Tolerant of nil receivers so callers can probe before +// the health check completes. +func (h *HealthResponse) HasCapability(c string) bool { + if h == nil { + return false + } + for _, cap := range h.Capabilities { + if cap == c { + return true + } + } + return false +} + +// Namespace is the persisted namespace state returned by upsert/patch +// and embedded in audit responses. +type Namespace struct { + Name string `json:"name"` + Kind NamespaceKind `json:"kind"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// NamespaceUpsert is the body of PUT /v1/namespaces/{name}. +type NamespaceUpsert struct { + Kind NamespaceKind `json:"kind"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// NamespacePatch is the body of PATCH /v1/namespaces/{name}. +type NamespacePatch struct { + ExpiresAt *time.Time `json:"expires_at,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// MemoryWrite is the body of POST /v1/namespaces/{name}/memories. +// +// `Content` MUST be pre-redacted by workspace-server (SAFE-T1201). +// Plugins do not run additional redaction; the workspace-server is the +// security perimeter. +type MemoryWrite struct { + Content string `json:"content"` + Kind MemoryKind `json:"kind"` + Source MemorySource `json:"source"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + Propagation map[string]interface{} `json:"propagation,omitempty"` + Pin bool `json:"pin,omitempty"` + Embedding []float32 `json:"embedding,omitempty"` +} + +// MemoryWriteResponse is the body of 201 from POST .../memories. +type MemoryWriteResponse struct { + ID string `json:"id"` + Namespace string `json:"namespace"` +} + +// Memory is a stored memory record returned by search. +type Memory struct { + ID string `json:"id"` + Namespace string `json:"namespace"` + Content string `json:"content"` + Kind MemoryKind `json:"kind"` + Source MemorySource `json:"source"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + Propagation map[string]interface{} `json:"propagation,omitempty"` + Pin bool `json:"pin,omitempty"` + CreatedAt time.Time `json:"created_at"` + Score *float64 `json:"score,omitempty"` +} + +// SearchRequest is the body of POST /v1/search. +// +// `Namespaces` MUST already be intersected with the caller's readable +// set by workspace-server. The plugin treats it as authoritative. +type SearchRequest struct { + Namespaces []string `json:"namespaces"` + Query string `json:"query,omitempty"` + Kinds []MemoryKind `json:"kinds,omitempty"` + Limit int `json:"limit,omitempty"` + Embedding []float32 `json:"embedding,omitempty"` +} + +// SearchResponse is the body of 200 from POST /v1/search. +type SearchResponse struct { + Memories []Memory `json:"memories"` +} + +// ForgetRequest is the body of DELETE /v1/memories/{id}. +type ForgetRequest struct { + RequestedByNamespace string `json:"requested_by_namespace"` +} + +// Error is the standard error envelope for non-2xx responses. +type Error struct { + Code ErrorCode `json:"code"` + Message string `json:"message"` + Details map[string]interface{} `json:"details,omitempty"` +} + +func (e *Error) Error() string { + if e == nil { + return "" + } + return fmt.Sprintf("memory-plugin: %s: %s", e.Code, e.Message) +} + +// --- Validation --- + +// Per the OpenAPI spec: lowercase prefix, colon, then alnum + a small +// set of separators. Caps the length at 256 to bound storage. +var namespacePattern = regexp.MustCompile(`^[a-z]+:[A-Za-z0-9_:.\-]+$`) + +const maxNamespaceLen = 256 + +// ValidateNamespaceName enforces the wire-level namespace string +// format. Run by both client (before request) and server (on receive). +func ValidateNamespaceName(name string) error { + if name == "" { + return errors.New("namespace name is empty") + } + if len(name) > maxNamespaceLen { + return fmt.Errorf("namespace name exceeds %d chars", maxNamespaceLen) + } + if !namespacePattern.MatchString(name) { + return fmt.Errorf("namespace name %q does not match required pattern %s", + name, namespacePattern.String()) + } + return nil +} + +// Validate checks NamespaceUpsert against the OpenAPI constraints. +func (u *NamespaceUpsert) Validate() error { + if u == nil { + return errors.New("nil NamespaceUpsert") + } + if !validNamespaceKind(u.Kind) { + return fmt.Errorf("invalid namespace kind %q", u.Kind) + } + return nil +} + +// Validate checks NamespacePatch is at least one mutation. An entirely +// empty patch is rejected so callers don't waste round-trips. +func (p *NamespacePatch) Validate() error { + if p == nil { + return errors.New("nil NamespacePatch") + } + if p.ExpiresAt == nil && p.Metadata == nil { + return errors.New("patch has no fields set") + } + return nil +} + +// Validate checks MemoryWrite. Empty content is rejected (zero-length +// memories are pure overhead). Both kind and source are required. +func (w *MemoryWrite) Validate() error { + if w == nil { + return errors.New("nil MemoryWrite") + } + if strings.TrimSpace(w.Content) == "" { + return errors.New("content is empty") + } + if !validMemoryKind(w.Kind) { + return fmt.Errorf("invalid memory kind %q", w.Kind) + } + if !validMemorySource(w.Source) { + return fmt.Errorf("invalid memory source %q", w.Source) + } + return nil +} + +// Validate checks SearchRequest. The namespace list must be non-empty +// because workspace-server is required to intersect server-side; an +// empty list at this layer is a bug, not a "search everything" intent. +func (s *SearchRequest) Validate() error { + if s == nil { + return errors.New("nil SearchRequest") + } + if len(s.Namespaces) == 0 { + return errors.New("namespaces is empty (workspace-server must intersect, not the plugin)") + } + for i, ns := range s.Namespaces { + if err := ValidateNamespaceName(ns); err != nil { + return fmt.Errorf("namespaces[%d]: %w", i, err) + } + } + if s.Limit < 0 || s.Limit > 100 { + return fmt.Errorf("limit %d out of range [0,100]", s.Limit) + } + for i, k := range s.Kinds { + if !validMemoryKind(k) { + return fmt.Errorf("kinds[%d]: invalid memory kind %q", i, k) + } + } + return nil +} + +// Validate checks ForgetRequest. +func (f *ForgetRequest) Validate() error { + if f == nil { + return errors.New("nil ForgetRequest") + } + return ValidateNamespaceName(f.RequestedByNamespace) +} + +func validNamespaceKind(k NamespaceKind) bool { + switch k { + case NamespaceKindWorkspace, NamespaceKindTeam, NamespaceKindOrg, NamespaceKindCustom: + return true + } + return false +} + +func validMemoryKind(k MemoryKind) bool { + switch k { + case MemoryKindFact, MemoryKindSummary, MemoryKindCheckpoint: + return true + } + return false +} + +func validMemorySource(s MemorySource) bool { + switch s { + case MemorySourceAgent, MemorySourceRuntime, MemorySourceUser: + return true + } + return false +} diff --git a/workspace-server/internal/memory/contract/contract_test.go b/workspace-server/internal/memory/contract/contract_test.go new file mode 100644 index 00000000..638c351d --- /dev/null +++ b/workspace-server/internal/memory/contract/contract_test.go @@ -0,0 +1,527 @@ +package contract + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// --- HealthResponse --- + +func TestHealthResponse_HasCapability(t *testing.T) { + cases := []struct { + name string + h *HealthResponse + cap string + want bool + }{ + {"nil receiver", nil, CapabilityEmbedding, false}, + {"empty caps", &HealthResponse{Capabilities: nil}, CapabilityEmbedding, false}, + {"present", &HealthResponse{Capabilities: []string{CapabilityFTS, CapabilityEmbedding}}, CapabilityEmbedding, true}, + {"absent", &HealthResponse{Capabilities: []string{CapabilityFTS}}, CapabilityEmbedding, false}, + {"unknown cap string", &HealthResponse{Capabilities: []string{"future-cap"}}, "future-cap", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.h.HasCapability(tc.cap); got != tc.want { + t.Errorf("HasCapability(%q) = %v, want %v", tc.cap, got, tc.want) + } + }) + } +} + +// --- ValidateNamespaceName --- + +func TestValidateNamespaceName(t *testing.T) { + cases := []struct { + name string + in string + wantErr bool + }{ + {"empty", "", true}, + {"workspace uuid", "workspace:550e8400-e29b-41d4-a716-446655440000", false}, + {"team uuid", "team:550e8400-e29b-41d4-a716-446655440000", false}, + {"org slug", "org:acme-corp", false}, + {"custom slug", "custom:engineering-shared", false}, + {"no colon", "workspace_self", true}, + {"empty prefix", ":foo", true}, + {"empty body", "workspace:", true}, + {"uppercase prefix", "WORKSPACE:abc", true}, + {"prefix with digit", "ws1:abc", true}, + {"body with space", "workspace:abc def", true}, + {"body with slash", "workspace:abc/def", true}, + {"valid with dots", "workspace:abc.def.ghi", false}, + {"valid with underscores", "workspace:abc_def", false}, + {"valid with double colon in body", "team:abc:def", false}, + {"too long", "workspace:" + strings.Repeat("a", 257), true}, + {"exactly max", "workspace:" + strings.Repeat("a", maxNamespaceLen-len("workspace:")), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateNamespaceName(tc.in) + if (err != nil) != tc.wantErr { + t.Errorf("ValidateNamespaceName(%q) err=%v, wantErr=%v", tc.in, err, tc.wantErr) + } + }) + } +} + +// --- NamespaceUpsert.Validate --- + +func TestNamespaceUpsert_Validate(t *testing.T) { + cases := []struct { + name string + in *NamespaceUpsert + wantErr bool + }{ + {"nil", nil, true}, + {"workspace kind", &NamespaceUpsert{Kind: NamespaceKindWorkspace}, false}, + {"team kind", &NamespaceUpsert{Kind: NamespaceKindTeam}, false}, + {"org kind", &NamespaceUpsert{Kind: NamespaceKindOrg}, false}, + {"custom kind", &NamespaceUpsert{Kind: NamespaceKindCustom}, false}, + {"empty kind", &NamespaceUpsert{Kind: ""}, true}, + {"unknown kind", &NamespaceUpsert{Kind: "futurekind"}, true}, + {"with TTL", &NamespaceUpsert{Kind: NamespaceKindTeam, ExpiresAt: timePtr(time.Now().Add(time.Hour))}, false}, + {"with metadata", &NamespaceUpsert{Kind: NamespaceKindOrg, Metadata: map[string]interface{}{"tier": "pro"}}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.in.Validate() + if (err != nil) != tc.wantErr { + t.Errorf("Validate() err=%v, wantErr=%v", err, tc.wantErr) + } + }) + } +} + +// --- NamespacePatch.Validate --- + +func TestNamespacePatch_Validate(t *testing.T) { + cases := []struct { + name string + in *NamespacePatch + wantErr bool + }{ + {"nil", nil, true}, + {"empty patch", &NamespacePatch{}, true}, + {"only TTL", &NamespacePatch{ExpiresAt: timePtr(time.Now())}, false}, + {"only metadata", &NamespacePatch{Metadata: map[string]interface{}{"k": "v"}}, false}, + {"both fields", &NamespacePatch{ExpiresAt: timePtr(time.Now()), Metadata: map[string]interface{}{"k": "v"}}, false}, + // Note: empty (non-nil) metadata map IS considered a mutation — + // it lets operators clear metadata by sending {}. + {"empty metadata map mutates", &NamespacePatch{Metadata: map[string]interface{}{}}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.in.Validate() + if (err != nil) != tc.wantErr { + t.Errorf("Validate() err=%v, wantErr=%v", err, tc.wantErr) + } + }) + } +} + +// --- MemoryWrite.Validate --- + +func TestMemoryWrite_Validate(t *testing.T) { + valid := func(mut func(*MemoryWrite)) *MemoryWrite { + w := &MemoryWrite{ + Content: "user prefers tabs", + Kind: MemoryKindFact, + Source: MemorySourceAgent, + } + if mut != nil { + mut(w) + } + return w + } + cases := []struct { + name string + in *MemoryWrite + wantErr bool + }{ + {"nil", nil, true}, + {"happy path", valid(nil), false}, + {"empty content", valid(func(w *MemoryWrite) { w.Content = "" }), true}, + {"whitespace-only content", valid(func(w *MemoryWrite) { w.Content = " \t\n " }), true}, + {"summary kind", valid(func(w *MemoryWrite) { w.Kind = MemoryKindSummary }), false}, + {"checkpoint kind", valid(func(w *MemoryWrite) { w.Kind = MemoryKindCheckpoint }), false}, + {"empty kind", valid(func(w *MemoryWrite) { w.Kind = "" }), true}, + {"unknown kind", valid(func(w *MemoryWrite) { w.Kind = "rumor" }), true}, + {"runtime source", valid(func(w *MemoryWrite) { w.Source = MemorySourceRuntime }), false}, + {"user source", valid(func(w *MemoryWrite) { w.Source = MemorySourceUser }), false}, + {"empty source", valid(func(w *MemoryWrite) { w.Source = "" }), true}, + {"unknown source", valid(func(w *MemoryWrite) { w.Source = "spy" }), true}, + {"with embedding", valid(func(w *MemoryWrite) { w.Embedding = []float32{0.1, 0.2, 0.3} }), false}, + {"with TTL", valid(func(w *MemoryWrite) { w.ExpiresAt = timePtr(time.Now().Add(time.Hour)) }), false}, + {"with propagation", valid(func(w *MemoryWrite) { w.Propagation = map[string]interface{}{"hop": 1} }), false}, + {"pin true", valid(func(w *MemoryWrite) { w.Pin = true }), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.in.Validate() + if (err != nil) != tc.wantErr { + t.Errorf("Validate() err=%v, wantErr=%v", err, tc.wantErr) + } + }) + } +} + +// --- SearchRequest.Validate --- + +func TestSearchRequest_Validate(t *testing.T) { + cases := []struct { + name string + in *SearchRequest + wantErr bool + }{ + {"nil", nil, true}, + {"empty namespaces", &SearchRequest{}, true}, + {"single ns", &SearchRequest{Namespaces: []string{"workspace:abc"}}, false}, + {"multi ns", &SearchRequest{Namespaces: []string{"workspace:abc", "team:def", "org:ghi"}}, false}, + {"invalid ns in list", &SearchRequest{Namespaces: []string{"workspace:abc", "BAD"}}, true}, + {"limit zero", &SearchRequest{Namespaces: []string{"workspace:abc"}, Limit: 0}, false}, + {"limit max", &SearchRequest{Namespaces: []string{"workspace:abc"}, Limit: 100}, false}, + {"limit too high", &SearchRequest{Namespaces: []string{"workspace:abc"}, Limit: 101}, true}, + {"limit negative", &SearchRequest{Namespaces: []string{"workspace:abc"}, Limit: -1}, true}, + {"valid kinds", &SearchRequest{Namespaces: []string{"workspace:abc"}, Kinds: []MemoryKind{MemoryKindFact, MemoryKindSummary}}, false}, + {"invalid kind in list", &SearchRequest{Namespaces: []string{"workspace:abc"}, Kinds: []MemoryKind{"bogus"}}, true}, + {"with query and embedding", &SearchRequest{Namespaces: []string{"workspace:abc"}, Query: "prefs", Embedding: []float32{1, 2, 3}}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.in.Validate() + if (err != nil) != tc.wantErr { + t.Errorf("Validate() err=%v, wantErr=%v", err, tc.wantErr) + } + }) + } +} + +// --- ForgetRequest.Validate --- + +func TestForgetRequest_Validate(t *testing.T) { + cases := []struct { + name string + in *ForgetRequest + wantErr bool + }{ + {"nil", nil, true}, + {"empty ns", &ForgetRequest{}, true}, + {"valid ns", &ForgetRequest{RequestedByNamespace: "workspace:abc"}, false}, + {"invalid ns", &ForgetRequest{RequestedByNamespace: "no-colon"}, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.in.Validate() + if (err != nil) != tc.wantErr { + t.Errorf("Validate() err=%v, wantErr=%v", err, tc.wantErr) + } + }) + } +} + +// --- Error type --- + +func TestError_Error(t *testing.T) { + cases := []struct { + name string + in *Error + want string + }{ + {"nil", nil, ""}, + {"basic", &Error{Code: ErrorCodeNotFound, Message: "ns gone"}, "memory-plugin: not_found: ns gone"}, + {"with details", &Error{Code: ErrorCodeInternal, Message: "boom", Details: map[string]interface{}{"trace": "x"}}, "memory-plugin: internal: boom"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.in.Error(); got != tc.want { + t.Errorf("Error() = %q, want %q", got, tc.want) + } + }) + } + + // Verifies Error implements the standard error interface so callers + // can use errors.As/errors.Is. This was missed pre-PR; an incident + // in PR #2509 was caused by a type that looked like an error but + // wasn't assertable, so we pin the contract explicitly. + var e error = &Error{Code: ErrorCodeBadRequest, Message: "x"} + var target *Error + if !errors.As(e, &target) { + t.Errorf("Error must satisfy errors.As to *Error") + } +} + +// --- Round-trip JSON tests for every type --- + +func TestRoundTrip_HealthResponse(t *testing.T) { + original := HealthResponse{ + Status: "ok", + Version: SchemaVersion, + Capabilities: []string{CapabilityFTS, CapabilityEmbedding, CapabilityTTL}, + } + roundTripJSON(t, original, &HealthResponse{}, func(got, want interface{}) { + g := got.(*HealthResponse) + w := want.(HealthResponse) + if g.Status != w.Status || g.Version != w.Version { + t.Errorf("status/version mismatch") + } + if len(g.Capabilities) != len(w.Capabilities) { + t.Errorf("capabilities len mismatch: got %d want %d", len(g.Capabilities), len(w.Capabilities)) + } + }) +} + +func TestRoundTrip_Namespace(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + exp := now.Add(24 * time.Hour) + original := Namespace{ + Name: "workspace:550e8400-e29b-41d4-a716-446655440000", + Kind: NamespaceKindWorkspace, + ExpiresAt: &exp, + Metadata: map[string]interface{}{"owner": "agent-x"}, + CreatedAt: now, + } + roundTripJSON(t, original, &Namespace{}, nil) +} + +func TestRoundTrip_NamespaceUpsert(t *testing.T) { + exp := time.Now().UTC().Add(time.Hour).Truncate(time.Second) + original := NamespaceUpsert{ + Kind: NamespaceKindTeam, + ExpiresAt: &exp, + Metadata: map[string]interface{}{"tier": "pro"}, + } + roundTripJSON(t, original, &NamespaceUpsert{}, nil) +} + +func TestRoundTrip_NamespacePatch(t *testing.T) { + exp := time.Now().UTC().Truncate(time.Second) + original := NamespacePatch{ + ExpiresAt: &exp, + Metadata: map[string]interface{}{"k": "v"}, + } + roundTripJSON(t, original, &NamespacePatch{}, nil) +} + +func TestRoundTrip_MemoryWrite(t *testing.T) { + exp := time.Now().UTC().Add(time.Hour).Truncate(time.Second) + original := MemoryWrite{ + Content: "remembered fact", + Kind: MemoryKindFact, + Source: MemorySourceAgent, + ExpiresAt: &exp, + Propagation: map[string]interface{}{"hop": float64(1)}, + Pin: true, + Embedding: []float32{0.1, 0.2, 0.3}, + } + roundTripJSON(t, original, &MemoryWrite{}, func(got, want interface{}) { + g := got.(*MemoryWrite) + w := want.(MemoryWrite) + if g.Content != w.Content || g.Kind != w.Kind || g.Source != w.Source { + t.Errorf("content/kind/source mismatch") + } + if g.Pin != w.Pin { + t.Errorf("pin mismatch") + } + if len(g.Embedding) != len(w.Embedding) { + t.Errorf("embedding len mismatch") + } + }) +} + +func TestRoundTrip_MemoryWriteResponse(t *testing.T) { + original := MemoryWriteResponse{ + ID: "550e8400-e29b-41d4-a716-446655440000", + Namespace: "workspace:abc", + } + roundTripJSON(t, original, &MemoryWriteResponse{}, nil) +} + +func TestRoundTrip_Memory(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + score := 0.87 + original := Memory{ + ID: "550e8400-e29b-41d4-a716-446655440000", + Namespace: "team:abc", + Content: "team agreed on tabs", + Kind: MemoryKindFact, + Source: MemorySourceAgent, + CreatedAt: now, + Score: &score, + } + roundTripJSON(t, original, &Memory{}, func(got, want interface{}) { + g := got.(*Memory) + w := want.(Memory) + if g.ID != w.ID || g.Namespace != w.Namespace { + t.Errorf("id/ns mismatch") + } + if g.Score == nil || *g.Score != *w.Score { + t.Errorf("score mismatch") + } + }) +} + +func TestRoundTrip_SearchRequest(t *testing.T) { + original := SearchRequest{ + Namespaces: []string{"workspace:abc", "team:def"}, + Query: "prefs", + Kinds: []MemoryKind{MemoryKindFact, MemoryKindSummary}, + Limit: 20, + Embedding: []float32{1, 2, 3}, + } + roundTripJSON(t, original, &SearchRequest{}, nil) +} + +func TestRoundTrip_SearchResponse(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + original := SearchResponse{ + Memories: []Memory{ + {ID: "id-1", Namespace: "workspace:abc", Content: "x", Kind: MemoryKindFact, Source: MemorySourceAgent, CreatedAt: now}, + {ID: "id-2", Namespace: "team:def", Content: "y", Kind: MemoryKindSummary, Source: MemorySourceRuntime, CreatedAt: now}, + }, + } + roundTripJSON(t, original, &SearchResponse{}, nil) +} + +func TestRoundTrip_ForgetRequest(t *testing.T) { + original := ForgetRequest{RequestedByNamespace: "workspace:abc"} + roundTripJSON(t, original, &ForgetRequest{}, nil) +} + +func TestRoundTrip_Error(t *testing.T) { + original := Error{ + Code: ErrorCodeBadRequest, + Message: "invalid input", + Details: map[string]interface{}{"field": "kind"}, + } + roundTripJSON(t, original, &Error{}, nil) +} + +// --- Golden vector tests --- +// +// These pin the exact wire shape against committed JSON files. If a +// future refactor accidentally changes a JSON tag or omits a field, the +// golden test fails. Update goldens via `go test -update` (env var +// based; see updateGoldens()). + +func TestGolden_HealthResponse_OK(t *testing.T) { + checkGolden(t, "health_ok.json", HealthResponse{ + Status: "ok", + Version: "1.0.0", + Capabilities: []string{"fts", "embedding"}, + }) +} + +func TestGolden_NamespaceUpsert_Workspace(t *testing.T) { + checkGolden(t, "namespace_upsert_workspace.json", NamespaceUpsert{ + Kind: NamespaceKindWorkspace, + }) +} + +func TestGolden_MemoryWrite_Minimal(t *testing.T) { + checkGolden(t, "memory_write_minimal.json", MemoryWrite{ + Content: "user prefers tabs over spaces", + Kind: MemoryKindFact, + Source: MemorySourceAgent, + }) +} + +func TestGolden_SearchRequest_MultiNamespace(t *testing.T) { + checkGolden(t, "search_request_multi_namespace.json", SearchRequest{ + Namespaces: []string{ + "workspace:550e8400-e29b-41d4-a716-446655440000", + "team:660e8400-e29b-41d4-a716-446655440001", + "org:acme-corp", + }, + Query: "indentation preferences", + Limit: 20, + }) +} + +func TestGolden_Error_NotFound(t *testing.T) { + checkGolden(t, "error_not_found.json", Error{ + Code: ErrorCodeNotFound, + Message: "namespace not found", + }) +} + +// --- Helpers --- + +func timePtr(t time.Time) *time.Time { return &t } + +// roundTripJSON marshals `original` to JSON, unmarshals into `got`, +// then validates the round-trip integrity. If `extra` is non-nil it +// runs additional type-specific assertions. +func roundTripJSON(t *testing.T, original interface{}, got interface{}, extra func(got, want interface{})) { + t.Helper() + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := json.Unmarshal(data, got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + // Re-marshal the unmarshaled value and compare to the original + // JSON. Catches asymmetric tag bugs (e.g., `omitempty` differences). + roundData, err := json.Marshal(got) + if err != nil { + t.Fatalf("re-marshal: %v", err) + } + if err := jsonEqual(data, roundData); err != nil { + t.Errorf("round-trip diverged:\n before: %s\n after: %s\n diff: %v", data, roundData, err) + } + if extra != nil { + extra(got, original) + } +} + +// jsonEqual compares two JSON byte slices semantically (key order +// independent, type-preserving). +func jsonEqual(a, b []byte) error { + var ax, bx interface{} + if err := json.Unmarshal(a, &ax); err != nil { + return fmt.Errorf("a unmarshal: %w", err) + } + if err := json.Unmarshal(b, &bx); err != nil { + return fmt.Errorf("b unmarshal: %w", err) + } + an, _ := json.Marshal(ax) + bn, _ := json.Marshal(bx) + if string(an) != string(bn) { + return fmt.Errorf("differ: %s vs %s", an, bn) + } + return nil +} + +func checkGolden(t *testing.T, filename string, value interface{}) { + t.Helper() + path := filepath.Join("testdata", filename) + got, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatalf("marshal: %v", err) + } + got = append(got, '\n') + + if updateGoldens() { + if err := os.WriteFile(path, got, 0644); err != nil { + t.Fatalf("write golden: %v", err) + } + return + } + + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read golden %s: %v (run with UPDATE_GOLDENS=1 to create)", path, err) + } + if string(got) != string(want) { + t.Errorf("golden %s mismatch:\n--- got ---\n%s\n--- want ---\n%s", path, got, want) + } +} + +func updateGoldens() bool { return os.Getenv("UPDATE_GOLDENS") == "1" } diff --git a/workspace-server/internal/memory/contract/testdata/error_not_found.json b/workspace-server/internal/memory/contract/testdata/error_not_found.json new file mode 100644 index 00000000..4a488470 --- /dev/null +++ b/workspace-server/internal/memory/contract/testdata/error_not_found.json @@ -0,0 +1,4 @@ +{ + "code": "not_found", + "message": "namespace not found" +} diff --git a/workspace-server/internal/memory/contract/testdata/health_ok.json b/workspace-server/internal/memory/contract/testdata/health_ok.json new file mode 100644 index 00000000..5b52e61e --- /dev/null +++ b/workspace-server/internal/memory/contract/testdata/health_ok.json @@ -0,0 +1,8 @@ +{ + "status": "ok", + "version": "1.0.0", + "capabilities": [ + "fts", + "embedding" + ] +} diff --git a/workspace-server/internal/memory/contract/testdata/memory_write_minimal.json b/workspace-server/internal/memory/contract/testdata/memory_write_minimal.json new file mode 100644 index 00000000..2b91f530 --- /dev/null +++ b/workspace-server/internal/memory/contract/testdata/memory_write_minimal.json @@ -0,0 +1,5 @@ +{ + "content": "user prefers tabs over spaces", + "kind": "fact", + "source": "agent" +} diff --git a/workspace-server/internal/memory/contract/testdata/namespace_upsert_workspace.json b/workspace-server/internal/memory/contract/testdata/namespace_upsert_workspace.json new file mode 100644 index 00000000..1de3a1ec --- /dev/null +++ b/workspace-server/internal/memory/contract/testdata/namespace_upsert_workspace.json @@ -0,0 +1,3 @@ +{ + "kind": "workspace" +} diff --git a/workspace-server/internal/memory/contract/testdata/search_request_multi_namespace.json b/workspace-server/internal/memory/contract/testdata/search_request_multi_namespace.json new file mode 100644 index 00000000..4be315cb --- /dev/null +++ b/workspace-server/internal/memory/contract/testdata/search_request_multi_namespace.json @@ -0,0 +1,9 @@ +{ + "namespaces": [ + "workspace:550e8400-e29b-41d4-a716-446655440000", + "team:660e8400-e29b-41d4-a716-446655440001", + "org:acme-corp" + ], + "query": "indentation preferences", + "limit": 20 +}