diff --git a/.changeset/retire-feed-contracts.md b/.changeset/retire-feed-contracts.md
new file mode 100644
index 0000000000..91021af2ec
--- /dev/null
+++ b/.changeset/retire-feed-contracts.md
@@ -0,0 +1,53 @@
+---
+"@objectstack/spec": minor
+"@objectstack/metadata-protocol": minor
+"@objectstack/client": minor
+"@objectstack/objectql": patch
+---
+
+**Breaking (npm type surface): retire the vestigial feed contracts + protocol surface (ADR-0052 §5 follow-up, #1959).**
+
+The `service-feed` runtime was deleted in #1955; `sys_comment` / `sys_activity`
+are the canonical record-collaboration/timeline backend. This removes the dead
+type surface that still pointed at the deleted runtime — every removed method was
+already unreachable (the feed REST route was never mounted → 404; the protocol
+implementation was never wired with a feed service, so `requireFeedService()`
+could only throw). No behavior changes.
+
+No authorable metadata key is removed (the `feeds:` object capability flag and
+the `RecordActivity` UI component config are unchanged), so `PROTOCOL_MAJOR`
+stays 15 and this ships as `minor` rather than a protocol major.
+
+FROM → TO migration for every removed export:
+
+- `@objectstack/spec/contracts` — `IFeedService`, `CreateFeedItemInput`,
+ `UpdateFeedItemInput`, `ListFeedOptions`, `FeedListResult` → **removed, no
+ replacement**. Comments/activity are plain records: write `sys_comment` / read
+ `sys_activity` via the data engine or the REST data API.
+- `@objectstack/spec/api` — `FeedApiContracts`, `FeedApiErrorCode`,
+ `FeedProtocol`, and all feed request/response schemas + types (`GetFeed*`,
+ `CreateFeedItem*`, `UpdateFeedItem*`, `DeleteFeedItem*`, `AddReaction*`,
+ `RemoveReaction*`, `PinFeedItem*`, `UnpinFeedItem*`, `StarFeedItem*`,
+ `UnstarFeedItem*`, `SearchFeed*`, `GetChangelog*`, `ChangelogEntry`,
+ `SubscribeRequest/Response`, `FeedUnsubscribeRequest`, `UnsubscribeResponse`,
+ `FeedPathParams`, `FeedItemPathParams`, `FeedListFilterType`) → **removed**. Use
+ the data API against `sys_comment` / `sys_activity` (`/api/v1/data/sys_comment/…`);
+ reactions and threaded replies are fields on `sys_comment`.
+- `@objectstack/spec/data` — `FeedItemSchema`/`FeedItem`, `FeedActorSchema`/`FeedActor`,
+ `MentionSchema`/`Mention`, `ReactionSchema`/`Reaction`,
+ `FieldChangeEntrySchema`/`FieldChangeEntry`, `FeedVisibility`,
+ `RecordSubscriptionSchema`/`RecordSubscription`, `SubscriptionEventType`, and the
+ `data`-namespace `NotificationChannel` → **removed**. `FeedItemType` and
+ `FeedFilterMode` are **kept** (live UI activity-timeline config). For notification
+ channels use `NotificationChannelSchema` from `@objectstack/spec/system`.
+- `@objectstack/client` — `client.feed.*` (`list` / `create` / `update` / `delete` /
+ `addReaction` / `removeReaction` / `pin` / `unpin` / `star` / `unstar` / `search` /
+ `getChangelog` / `subscribe` / `unsubscribe`) and the re-exported feed response
+ types → **removed**. One-line fix: use `client.data.*` on `sys_comment` /
+ `sys_activity`, e.g. `client.data.create('sys_comment', { object, record_id, body })`
+ and `client.data.find('sys_activity', { filters: [['record_id', '=', id]] })`.
+- `@objectstack/metadata-protocol` — `ObjectStackProtocolImplementation` no longer
+ implements the 14 feed methods; its constructor
+ `(engine, getServicesRegistry?, getFeedService?, environmentId?)` becomes
+ `(engine, getServicesRegistry?, environmentId?)`. One-line fix: delete the third
+ argument.
diff --git a/content/docs/references/api/feed-api.mdx b/content/docs/references/api/feed-api.mdx
deleted file mode 100644
index 6d91226c1d..0000000000
--- a/content/docs/references/api/feed-api.mdx
+++ /dev/null
@@ -1,542 +0,0 @@
----
-title: Feed Api
-description: Feed Api protocol schemas
----
-
-{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
-
-Feed / Chatter API Protocol
-
-Defines the HTTP interface for the unified activity timeline (Feed).
-
-Covers Feed CRUD, Emoji Reactions, Pin/Star, Search, Changelog,
-
-and Record Subscription endpoints.
-
-Base path: /api/data/\{object\}/\{recordId\}/feed
-
-@example Endpoints
-
-GET /api/data/\{object\}/\{recordId\}/feed — List feed items
-
-POST /api/data/\{object\}/\{recordId\}/feed — Create feed item
-
-PUT /api/data/\{object\}/\{recordId\}/feed/\{feedId\} — Update feed item
-
-DELETE /api/data/\{object\}/\{recordId\}/feed/\{feedId\} — Delete feed item
-
-POST /api/data/\{object\}/\{recordId\}/feed/\{feedId\}/reactions — Add reaction
-
-DELETE /api/data/\{object\}/\{recordId\}/feed/\{feedId\}/reactions/\{emoji\} — Remove reaction
-
-POST /api/data/\{object\}/\{recordId\}/feed/\{feedId\}/pin — Pin feed item
-
-DELETE /api/data/\{object\}/\{recordId\}/feed/\{feedId\}/pin — Unpin feed item
-
-POST /api/data/\{object\}/\{recordId\}/feed/\{feedId\}/star — Star feed item
-
-DELETE /api/data/\{object\}/\{recordId\}/feed/\{feedId\}/star — Unstar feed item
-
-GET /api/data/\{object\}/\{recordId\}/feed/search — Search feed items
-
-GET /api/data/\{object\}/\{recordId\}/changelog — Get field-level changelog
-
-POST /api/data/\{object\}/\{recordId\}/subscribe — Subscribe
-
-DELETE /api/data/\{object\}/\{recordId\}/subscribe — Unsubscribe
-
-
-**Source:** `packages/spec/src/api/feed-api.zod.ts`
-
-
-## TypeScript Usage
-
-```typescript
-import { AddReactionRequest, AddReactionResponse, ChangelogEntry, CreateFeedItemRequest, CreateFeedItemResponse, DeleteFeedItemRequest, DeleteFeedItemResponse, FeedApiErrorCode, FeedItemPathParams, FeedListFilterType, FeedPathParams, FeedUnsubscribeRequest, GetChangelogRequest, GetChangelogResponse, GetFeedRequest, GetFeedResponse, PinFeedItemRequest, PinFeedItemResponse, RemoveReactionRequest, RemoveReactionResponse, SearchFeedRequest, SearchFeedResponse, StarFeedItemRequest, StarFeedItemResponse, SubscribeRequest, SubscribeResponse, UnpinFeedItemRequest, UnpinFeedItemResponse, UnstarFeedItemRequest, UnstarFeedItemResponse, UnsubscribeResponse, UpdateFeedItemRequest, UpdateFeedItemResponse } from '@objectstack/spec/api';
-import type { AddReactionRequest, AddReactionResponse, ChangelogEntry, CreateFeedItemRequest, CreateFeedItemResponse, DeleteFeedItemRequest, DeleteFeedItemResponse, FeedApiErrorCode, FeedItemPathParams, FeedListFilterType, FeedPathParams, FeedUnsubscribeRequest, GetChangelogRequest, GetChangelogResponse, GetFeedRequest, GetFeedResponse, PinFeedItemRequest, PinFeedItemResponse, RemoveReactionRequest, RemoveReactionResponse, SearchFeedRequest, SearchFeedResponse, StarFeedItemRequest, StarFeedItemResponse, SubscribeRequest, SubscribeResponse, UnpinFeedItemRequest, UnpinFeedItemResponse, UnstarFeedItemRequest, UnstarFeedItemResponse, UnsubscribeResponse, UpdateFeedItemRequest, UpdateFeedItemResponse } from '@objectstack/spec/api';
-
-// Validate data
-const result = AddReactionRequest.parse(data);
-```
-
----
-
-## AddReactionRequest
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name (e.g., "account") |
-| **recordId** | `string` | ✅ | Record ID |
-| **feedId** | `string` | ✅ | Feed item ID |
-| **emoji** | `string` | ✅ | Emoji character or shortcode (e.g., "👍", ":thumbsup:") |
-
-
----
-
-## AddReactionResponse
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
-| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
-| **data** | `{ reactions: { emoji: string; userIds: string[]; count: integer }[] }` | ✅ | |
-
-
----
-
-## ChangelogEntry
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **id** | `string` | ✅ | Changelog entry ID |
-| **object** | `string` | ✅ | Object name |
-| **recordId** | `string` | ✅ | Record ID |
-| **actor** | `{ type: Enum<'user' \| 'system' \| 'service' \| 'automation'>; id: string; name?: string }` | ✅ | Who made the change |
-| **changes** | `{ field: string; fieldLabel?: string; oldValue?: any; newValue?: any; … }[]` | ✅ | Field-level changes |
-| **timestamp** | `string` | ✅ | When the change occurred |
-| **source** | `string` | optional | Change source (e.g., "API", "UI", "automation") |
-
-
----
-
-## CreateFeedItemRequest
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name (e.g., "account") |
-| **recordId** | `string` | ✅ | Record ID |
-| **type** | `Enum<'comment' \| 'field_change' \| 'task' \| 'event' \| 'email' \| 'call' \| 'note' \| 'file' \| 'record_create' \| 'record_delete' \| 'approval' \| 'sharing' \| 'system'>` | ✅ | Type of feed item to create |
-| **body** | `string` | optional | Rich text body (Markdown supported) |
-| **mentions** | `{ type: Enum<'user' \| 'team' \| 'record'>; id: string; name: string; offset: integer; … }[]` | optional | Mentioned users, teams, or records |
-| **parentId** | `string` | optional | Parent feed item ID for threaded replies |
-| **visibility** | `Enum<'public' \| 'internal' \| 'private'>` | ✅ | Visibility: public, internal, or private |
-
-
----
-
-## CreateFeedItemResponse
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
-| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
-| **data** | `{ id: string; type: Enum<'comment' \| 'field_change' \| 'task' \| 'event' \| 'email' \| 'call' \| 'note' \| 'file' \| 'record_create' \| 'record_delete' \| 'approval' \| 'sharing' \| 'system'>; object: string; recordId: string; … }` | ✅ | The created feed item |
-
-
----
-
-## DeleteFeedItemRequest
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name (e.g., "account") |
-| **recordId** | `string` | ✅ | Record ID |
-| **feedId** | `string` | ✅ | Feed item ID |
-
-
----
-
-## DeleteFeedItemResponse
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
-| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
-| **data** | `{ feedId: string }` | ✅ | |
-
-
----
-
-## FeedApiErrorCode
-
-### Allowed Values
-
-* `feed_item_not_found`
-* `feed_permission_denied`
-* `feed_item_not_editable`
-* `feed_invalid_parent`
-* `reaction_already_exists`
-* `reaction_not_found`
-* `subscription_already_exists`
-* `subscription_not_found`
-* `invalid_feed_type`
-* `feed_already_pinned`
-* `feed_not_pinned`
-* `feed_already_starred`
-* `feed_not_starred`
-* `feed_search_query_too_short`
-
-
----
-
-## FeedItemPathParams
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name (e.g., "account") |
-| **recordId** | `string` | ✅ | Record ID |
-| **feedId** | `string` | ✅ | Feed item ID |
-
-
----
-
-## FeedListFilterType
-
-### Allowed Values
-
-* `all`
-* `comments_only`
-* `changes_only`
-* `tasks_only`
-
-
----
-
-## FeedPathParams
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name (e.g., "account") |
-| **recordId** | `string` | ✅ | Record ID |
-
-
----
-
-## FeedUnsubscribeRequest
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name (e.g., "account") |
-| **recordId** | `string` | ✅ | Record ID |
-
-
----
-
-## GetChangelogRequest
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name (e.g., "account") |
-| **recordId** | `string` | ✅ | Record ID |
-| **field** | `string` | optional | Filter changelog to a specific field name |
-| **actorId** | `string` | optional | Filter changelog by actor user ID |
-| **dateFrom** | `string` | optional | Filter changes after this timestamp |
-| **dateTo** | `string` | optional | Filter changes before this timestamp |
-| **limit** | `integer` | ✅ | Maximum number of changelog entries to return |
-| **cursor** | `string` | optional | Cursor for pagination |
-
-
----
-
-## GetChangelogResponse
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
-| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
-| **data** | `{ entries: { id: string; object: string; recordId: string; actor: object; … }[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | |
-
-
----
-
-## GetFeedRequest
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name (e.g., "account") |
-| **recordId** | `string` | ✅ | Record ID |
-| **type** | `Enum<'all' \| 'comments_only' \| 'changes_only' \| 'tasks_only'>` | ✅ | Filter by feed item category |
-| **limit** | `integer` | ✅ | Maximum number of items to return |
-| **cursor** | `string` | optional | Cursor for pagination (opaque string from previous response) |
-
-
----
-
-## GetFeedResponse
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
-| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
-| **data** | `{ items: { id: string; type: Enum<'comment' \| 'field_change' \| 'task' \| 'event' \| 'email' \| 'call' \| 'note' \| 'file' \| 'record_create' \| 'record_delete' \| 'approval' \| 'sharing' \| 'system'>; object: string; recordId: string; … }[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | |
-
-
----
-
-## PinFeedItemRequest
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name (e.g., "account") |
-| **recordId** | `string` | ✅ | Record ID |
-| **feedId** | `string` | ✅ | Feed item ID |
-
-
----
-
-## PinFeedItemResponse
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
-| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
-| **data** | `{ feedId: string; pinned: boolean; pinnedAt: string }` | ✅ | |
-
-
----
-
-## RemoveReactionRequest
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name (e.g., "account") |
-| **recordId** | `string` | ✅ | Record ID |
-| **feedId** | `string` | ✅ | Feed item ID |
-| **emoji** | `string` | ✅ | Emoji character or shortcode to remove |
-
-
----
-
-## RemoveReactionResponse
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
-| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
-| **data** | `{ reactions: { emoji: string; userIds: string[]; count: integer }[] }` | ✅ | |
-
-
----
-
-## SearchFeedRequest
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name (e.g., "account") |
-| **recordId** | `string` | ✅ | Record ID |
-| **query** | `string` | ✅ | Full-text search query against feed body content |
-| **type** | `Enum<'all' \| 'comments_only' \| 'changes_only' \| 'tasks_only'>` | optional | Filter by feed item category |
-| **actorId** | `string` | optional | Filter by actor user ID |
-| **dateFrom** | `string` | optional | Filter feed items created after this timestamp |
-| **dateTo** | `string` | optional | Filter feed items created before this timestamp |
-| **hasAttachments** | `boolean` | optional | Filter for items with file attachments |
-| **pinnedOnly** | `boolean` | optional | Return only pinned items |
-| **starredOnly** | `boolean` | optional | Return only starred items |
-| **limit** | `integer` | ✅ | Maximum number of items to return |
-| **cursor** | `string` | optional | Cursor for pagination |
-
-
----
-
-## SearchFeedResponse
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
-| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
-| **data** | `{ items: { id: string; type: Enum<'comment' \| 'field_change' \| 'task' \| 'event' \| 'email' \| 'call' \| 'note' \| 'file' \| 'record_create' \| 'record_delete' \| 'approval' \| 'sharing' \| 'system'>; object: string; recordId: string; … }[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | |
-
-
----
-
-## StarFeedItemRequest
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name (e.g., "account") |
-| **recordId** | `string` | ✅ | Record ID |
-| **feedId** | `string` | ✅ | Feed item ID |
-
-
----
-
-## StarFeedItemResponse
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
-| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
-| **data** | `{ feedId: string; starred: boolean; starredAt: string }` | ✅ | |
-
-
----
-
-## SubscribeRequest
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name (e.g., "account") |
-| **recordId** | `string` | ✅ | Record ID |
-| **events** | `Enum<'comment' \| 'mention' \| 'field_change' \| 'task' \| 'approval' \| 'all'>[]` | ✅ | Event types to subscribe to |
-| **channels** | `Enum<'in_app' \| 'email' \| 'push' \| 'slack'>[]` | ✅ | Notification delivery channels |
-
-
----
-
-## SubscribeResponse
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
-| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
-| **data** | `{ object: string; recordId: string; userId: string; events: Enum<'comment' \| 'mention' \| 'field_change' \| 'task' \| 'approval' \| 'all'>[]; … }` | ✅ | The created or updated subscription |
-
-
----
-
-## UnpinFeedItemRequest
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name (e.g., "account") |
-| **recordId** | `string` | ✅ | Record ID |
-| **feedId** | `string` | ✅ | Feed item ID |
-
-
----
-
-## UnpinFeedItemResponse
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
-| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
-| **data** | `{ feedId: string; pinned: boolean }` | ✅ | |
-
-
----
-
-## UnstarFeedItemRequest
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name (e.g., "account") |
-| **recordId** | `string` | ✅ | Record ID |
-| **feedId** | `string` | ✅ | Feed item ID |
-
-
----
-
-## UnstarFeedItemResponse
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
-| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
-| **data** | `{ feedId: string; starred: boolean }` | ✅ | |
-
-
----
-
-## UnsubscribeResponse
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
-| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
-| **data** | `{ object: string; recordId: string; unsubscribed: boolean }` | ✅ | |
-
-
----
-
-## UpdateFeedItemRequest
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name (e.g., "account") |
-| **recordId** | `string` | ✅ | Record ID |
-| **feedId** | `string` | ✅ | Feed item ID |
-| **body** | `string` | optional | Updated rich text body |
-| **mentions** | `{ type: Enum<'user' \| 'team' \| 'record'>; id: string; name: string; offset: integer; … }[]` | optional | Updated mentions |
-| **visibility** | `Enum<'public' \| 'internal' \| 'private'>` | optional | Updated visibility |
-
-
----
-
-## UpdateFeedItemResponse
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
-| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
-| **data** | `{ id: string; type: Enum<'comment' \| 'field_change' \| 'task' \| 'event' \| 'email' \| 'call' \| 'note' \| 'file' \| 'record_create' \| 'record_delete' \| 'approval' \| 'sharing' \| 'system'>; object: string; recordId: string; … }` | ✅ | The updated feed item |
-
-
----
-
diff --git a/content/docs/references/api/index.mdx b/content/docs/references/api/index.mdx
index f74d77f65c..42d4680c3e 100644
--- a/content/docs/references/api/index.mdx
+++ b/content/docs/references/api/index.mdx
@@ -19,7 +19,6 @@ This section contains all protocol schemas for the api layer of ObjectStack.
-
diff --git a/content/docs/references/api/meta.json b/content/docs/references/api/meta.json
index eefad9f2c8..b92de43606 100644
--- a/content/docs/references/api/meta.json
+++ b/content/docs/references/api/meta.json
@@ -16,7 +16,6 @@
"errors",
"events",
"export",
- "feed-api",
"graphql",
"http",
"http-cache",
diff --git a/content/docs/references/data/feed.mdx b/content/docs/references/data/feed.mdx
index ce30737cc7..43161ef518 100644
--- a/content/docs/references/data/feed.mdx
+++ b/content/docs/references/data/feed.mdx
@@ -5,11 +5,17 @@ description: Feed protocol schemas
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
-Feed Item Type
+Activity-timeline UI config enums.
-Unified activity types for the record timeline.
+The `service-feed` backend was retired (ADR-0052 §5 / #1955); `sys_comment` /
-Covers comments, field changes, tasks, events, and system activities.
+`sys_activity` are the canonical record-collaboration/timeline backend. Only these
+
+two enums remain here — they are pure UI configuration for the record activity
+
+component (`RecordActivityProps` in `../[ui/component.zod.ts](/docs/references/ui/component)`), with no backend
+
+dependency. (A later `feed` → `activity` rename is tracked separately.)
**Source:** `packages/spec/src/data/feed.zod.ts`
@@ -18,28 +24,13 @@ Covers comments, field changes, tasks, events, and system activities.
## TypeScript Usage
```typescript
-import { FeedActor, FeedFilterMode, FeedItem, FeedItemType, FeedVisibility, FieldChangeEntry, Mention, Reaction } from '@objectstack/spec/data';
-import type { FeedActor, FeedFilterMode, FeedItem, FeedItemType, FeedVisibility, FieldChangeEntry, Mention, Reaction } from '@objectstack/spec/data';
+import { FeedFilterMode, FeedItemType } from '@objectstack/spec/data';
+import type { FeedFilterMode, FeedItemType } from '@objectstack/spec/data';
// Validate data
-const result = FeedActor.parse(data);
+const result = FeedFilterMode.parse(data);
```
----
-
-## FeedActor
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **type** | `Enum<'user' \| 'system' \| 'service' \| 'automation'>` | ✅ | Actor type |
-| **id** | `string` | ✅ | Actor ID |
-| **name** | `string` | optional | Actor display name |
-| **avatarUrl** | `string` | optional | Actor avatar URL |
-| **source** | `string` | optional | Source application (e.g., "Omni", "API", "Studio") |
-
-
---
## FeedFilterMode
@@ -52,37 +43,6 @@ const result = FeedActor.parse(data);
* `tasks_only`
----
-
-## FeedItem
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **id** | `string` | ✅ | Feed item ID |
-| **type** | `Enum<'comment' \| 'field_change' \| 'task' \| 'event' \| 'email' \| 'call' \| 'note' \| 'file' \| 'record_create' \| 'record_delete' \| 'approval' \| 'sharing' \| 'system'>` | ✅ | Activity type |
-| **object** | `string` | ✅ | Object name (e.g., "account") |
-| **recordId** | `string` | ✅ | Record ID this feed item belongs to |
-| **actor** | `{ type: Enum<'user' \| 'system' \| 'service' \| 'automation'>; id: string; name?: string; avatarUrl?: string; … }` | ✅ | Who performed this action |
-| **body** | `string` | optional | Rich text body (Markdown supported) |
-| **mentions** | `{ type: Enum<'user' \| 'team' \| 'record'>; id: string; name: string; offset: integer; … }[]` | optional | Mentioned users/teams/records |
-| **changes** | `{ field: string; fieldLabel?: string; oldValue?: any; newValue?: any; … }[]` | optional | Field-level changes |
-| **reactions** | `{ emoji: string; userIds: string[]; count: integer }[]` | optional | Emoji reactions on this item |
-| **parentId** | `string` | optional | Parent feed item ID for threaded replies |
-| **replyCount** | `integer` | ✅ | Number of replies |
-| **pinned** | `boolean` | ✅ | Whether the feed item is pinned to the top of the timeline |
-| **pinnedAt** | `string` | optional | Timestamp when the item was pinned |
-| **pinnedBy** | `string` | optional | User ID who pinned the item |
-| **starred** | `boolean` | ✅ | Whether the feed item is starred/bookmarked by the current user |
-| **starredAt** | `string` | optional | Timestamp when the item was starred |
-| **visibility** | `Enum<'public' \| 'internal' \| 'private'>` | ✅ | Visibility: public (all users), internal (team only), private (author + mentioned) |
-| **createdAt** | `string` | ✅ | Creation timestamp |
-| **updatedAt** | `string` | optional | Last update timestamp |
-| **editedAt** | `string` | optional | When comment was last edited |
-| **isEdited** | `boolean` | ✅ | Whether comment has been edited |
-
-
---
## FeedItemType
@@ -106,58 +66,3 @@ const result = FeedActor.parse(data);
---
-## FeedVisibility
-
-### Allowed Values
-
-* `public`
-* `internal`
-* `private`
-
-
----
-
-## FieldChangeEntry
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **field** | `string` | ✅ | Field machine name |
-| **fieldLabel** | `string` | optional | Field display label |
-| **oldValue** | `any` | optional | Previous value |
-| **newValue** | `any` | optional | New value |
-| **oldDisplayValue** | `string` | optional | Human-readable old value |
-| **newDisplayValue** | `string` | optional | Human-readable new value |
-
-
----
-
-## Mention
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **type** | `Enum<'user' \| 'team' \| 'record'>` | ✅ | Mention target type |
-| **id** | `string` | ✅ | Target ID |
-| **name** | `string` | ✅ | Display name for rendering |
-| **offset** | `integer` | ✅ | Character offset in body text |
-| **length** | `integer` | ✅ | Length of mention token in body text |
-
-
----
-
-## Reaction
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **emoji** | `string` | ✅ | Emoji character or shortcode (e.g., "👍", ":thumbsup:") |
-| **userIds** | `string[]` | ✅ | Users who reacted |
-| **count** | `integer` | ✅ | Total reaction count |
-
-
----
-
diff --git a/content/docs/references/data/index.mdx b/content/docs/references/data/index.mdx
index 7465b23881..9498ac5742 100644
--- a/content/docs/references/data/index.mdx
+++ b/content/docs/references/data/index.mdx
@@ -26,6 +26,5 @@ This section contains all protocol schemas for the data layer of ObjectStack.
-
diff --git a/content/docs/references/data/meta.json b/content/docs/references/data/meta.json
index e6a34a191d..132f029f65 100644
--- a/content/docs/references/data/meta.json
+++ b/content/docs/references/data/meta.json
@@ -17,13 +17,11 @@
"hook",
"hook-body",
"mapping",
- "notification",
"object",
"query",
"search-engine",
"seed",
"seed-loader",
- "subscription",
"validation"
]
}
\ No newline at end of file
diff --git a/content/docs/references/data/notification.mdx b/content/docs/references/data/notification.mdx
deleted file mode 100644
index 57613fe7e9..0000000000
--- a/content/docs/references/data/notification.mdx
+++ /dev/null
@@ -1,35 +0,0 @@
----
-title: Notification
-description: Notification protocol schemas
----
-
-{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
-
-
-**Source:** `packages/spec/src/data/notification.zod.ts`
-
-
-## TypeScript Usage
-
-```typescript
-import { NotificationChannel } from '@objectstack/spec/data';
-import type { NotificationChannel } from '@objectstack/spec/data';
-
-// Validate data
-const result = NotificationChannel.parse(data);
-```
-
----
-
-## NotificationChannel
-
-### Allowed Values
-
-* `in_app`
-* `email`
-* `push`
-* `slack`
-
-
----
-
diff --git a/content/docs/references/data/subscription.mdx b/content/docs/references/data/subscription.mdx
deleted file mode 100644
index 2f4a516461..0000000000
--- a/content/docs/references/data/subscription.mdx
+++ /dev/null
@@ -1,58 +0,0 @@
----
-title: Subscription
-description: Subscription protocol schemas
----
-
-{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
-
-Subscription Event Type
-
-Event types that can be subscribed to for record-level notifications.
-
-
-**Source:** `packages/spec/src/data/subscription.zod.ts`
-
-
-## TypeScript Usage
-
-```typescript
-import { RecordSubscription, SubscriptionEventType } from '@objectstack/spec/data';
-import type { RecordSubscription, SubscriptionEventType } from '@objectstack/spec/data';
-
-// Validate data
-const result = RecordSubscription.parse(data);
-```
-
----
-
-## RecordSubscription
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name |
-| **recordId** | `string` | ✅ | Record ID |
-| **userId** | `string` | ✅ | Subscribing user ID |
-| **events** | `Enum<'comment' \| 'mention' \| 'field_change' \| 'task' \| 'approval' \| 'all'>[]` | ✅ | Event types to receive notifications for |
-| **channels** | `Enum<'in_app' \| 'email' \| 'push' \| 'slack'>[]` | ✅ | Notification delivery channels |
-| **active** | `boolean` | ✅ | Whether the subscription is active |
-| **createdAt** | `string` | ✅ | Subscription creation timestamp |
-
-
----
-
-## SubscriptionEventType
-
-### Allowed Values
-
-* `comment`
-* `mention`
-* `field_change`
-* `task`
-* `approval`
-* `all`
-
-
----
-
diff --git a/docs/adr/0052-audit-is-not-the-activity-feed.md b/docs/adr/0052-audit-is-not-the-activity-feed.md
index d16c216f0b..3df1128bd7 100644
--- a/docs/adr/0052-audit-is-not-the-activity-feed.md
+++ b/docs/adr/0052-audit-is-not-the-activity-feed.md
@@ -215,9 +215,15 @@ the split-brain this ADR exists to end, not extend.
The terminal state is **one backend, not two** — now realized: `service-feed`'s
runtime (the package + the `feed` capability) is removed; `sys_comment` /
-`sys_activity` stand alone. (The vestigial spec *contracts* — `feed.zod` /
-`feed-api.zod` / `IFeedService` — are a separate type-surface cleanup, since they
-are woven into `component.zod` / `protocol.zod` / objectql; tracked as follow-up.)
+`sys_activity` stand alone. The vestigial spec *contracts* — `feed-api.zod` /
+`IFeedService` / `data/subscription.zod`, plus the `FeedProtocol` slice of
+`protocol.zod`, the never-wired `getFeedService` plumbing in the protocol
+implementation, and the client `feed` SDK accessor — were **removed in #1959**.
+`data/feed.zod` is trimmed to just the `FeedItemType` / `FeedFilterMode` enums,
+which are live UI config for the record activity component (`component.zod`).
+(The residual discovery/dispatcher `feed` capability surface — `routes.feed`,
+`WellKnownCapabilities.feed`, the `/api/v1/feed` dispatcher entry — is a small
+separate follow-up, tracked as #3180.)
## 5b. Declarative activity — the platform generates it, apps don't code it
diff --git a/docs/adr/0076-objectql-core-tiering.md b/docs/adr/0076-objectql-core-tiering.md
index a92b9fe390..0b69ab0755 100644
--- a/docs/adr/0076-objectql-core-tiering.md
+++ b/docs/adr/0076-objectql-core-tiering.md
@@ -103,16 +103,16 @@ The segmentation is **spec/type-level and may start incrementally now** (define
### D10 — "protocol" is a contract, not an implementation package; distribute impls to domain packages [new — kernel review]
A single `ObjectStackProtocolImplementation` facade — and any `*-protocol` *implementation* package — is the wrong end-state. Verified against source:
-- The facade implements only **4 of the 11** contract domains (data, metadata, analytics, feed). The other 7 (realtime / notifications / workflow / ai / i18n / views / permissions) are **not implemented in it** — they belong to their own services or aren't implemented at all.
+- The facade implements only **4 of the 11** contract domains (data, metadata, analytics, feed). The other 7 (realtime / notifications / workflow / ai / i18n / views / permissions) are **not implemented in it** — they belong to their own services or aren't implemented at all. *(Update #1959: the feed domain was retired per ADR-0052 §5 — `IFeedService`, `FeedProtocol`, and the facade's feed forwarding no longer exist, so the facade now implements 3 domains: data, metadata, analytics.)*
- **Analytics uses a deliberate fallback + `replaceService` pattern (NOT a collision/bug — corrected in rev.9)**: `registerService` throws on duplicate (`core/kernel.ts`), so `ObjectQLPlugin` registers a lightweight ~66-line analytics **fallback** (so `/analytics` doesn't 404 in minimal deployments), and `AnalyticsServicePlugin`, when installed, calls **`ctx.replaceService('analytics', …)`** to swap in the full ~1.8k-LOC engine (three strategies incl. native-SQL). With service-analytics present the real engine serves (no shadowing); without it the fallback serves. The fallback duplicates *logic* only (a minor maintenance cost) and is intentional and harmless.
-- **Feed is already delegated** to `IFeedService`; the facade only forwards.
+- ~~**Feed is already delegated** to `IFeedService`; the facade only forwards.~~ *(Retired per ADR-0052 §5 / #1959 — `IFeedService` and the facade's feed forwarding were removed.)*
- The domain service packages already exist: `service-analytics`, `service-messaging`, `service-realtime`.
Target end-state:
- **"protocol" names ONLY the contract** — the segmented interfaces in `@objectstack/spec/api` (D9). There is **no `*-protocol` implementation package**.
- **`DataProtocol`** impl → engine-adjacent / transport (thin wire-normalizers).
- **`MetadataProtocol`** impl → stays in **`@objectstack/metadata-protocol`** (name **retained** — already published; renaming churns downstream for ~0 benefit). The package's *content* converges to the metadata-management impl (it owns `sys_metadata`). The `protocol` in the name is a deliberate, low-cost naming exception from being published — the real contract lives in `@objectstack/spec/api`, not here. (Open Question #7, resolved.)
-- **Analytics / Feed / Realtime / Notification / …** → each domain's *full* impl lives in its existing service package. For analytics specifically, the `ObjectQLPlugin` fallback **must be preserved or consciously dropped** (it prevents `/analytics` 404 for deployments without service-analytics) — **not blindly deleted**. Feed already delegates to `IFeedService`. The engine keeps only the minimal fallback it deliberately provides.
+- **Analytics / Realtime / Notification / …** → each domain's *full* impl lives in its existing service package. For analytics specifically, the `ObjectQLPlugin` fallback **must be preserved or consciously dropped** (it prevents `/analytics` 404 for deployments without service-analytics) — **not blindly deleted**. The engine keeps only the minimal fallback it deliberately provides. *(Feed was dropped entirely per ADR-0052 §5 / #1959 — no service package, no facade forwarding.)*
- **The transport/dispatcher routes each contract-slice to the owning service** (it already resolves services by name) — no central facade class.
This **refines D1** (the `metadata-protocol` package was an intermediate, not the end-state) and **completes D9**. Executed at the cross-repo window with D7 / Step 2.
diff --git a/packages/client/src/client.feed.test.ts b/packages/client/src/client.feed.test.ts
deleted file mode 100644
index da3904bf39..0000000000
--- a/packages/client/src/client.feed.test.ts
+++ /dev/null
@@ -1,273 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-import { describe, it, expect, vi } from 'vitest';
-import { ObjectStackClient } from './index';
-
-/** Helper: create a client with mocked fetch */
-function createMockClient(body: any, status = 200) {
- const fetchMock = vi.fn().mockResolvedValue({
- ok: status >= 200 && status < 300,
- status,
- statusText: status === 200 ? 'OK' : 'Error',
- json: async () => body,
- headers: new Headers()
- });
- const client = new ObjectStackClient({
- baseUrl: 'http://localhost:3000',
- fetch: fetchMock
- });
- return { client, fetchMock };
-}
-
-describe('ObjectStackClient - Feed Namespace', () => {
- // ==========================================
- // Feed CRUD
- // ==========================================
-
- it('feed.list should GET /api/v1/data/:object/:recordId/feed', async () => {
- const { client, fetchMock } = createMockClient({
- success: true,
- data: { items: [], total: 0, hasMore: false }
- });
-
- const result = await client.feed.list('account', 'rec_123', { type: 'all', limit: 10 });
-
- expect(fetchMock).toHaveBeenCalledWith(
- 'http://localhost:3000/api/v1/data/account/rec_123/feed?type=all&limit=10',
- expect.objectContaining({ headers: expect.any(Object) })
- );
- expect(result.items).toEqual([]);
- expect(result.hasMore).toBe(false);
- });
-
- it('feed.create should POST /api/v1/data/:object/:recordId/feed', async () => {
- const { client, fetchMock } = createMockClient({
- success: true,
- data: { id: 'feed_1', type: 'comment', body: 'Hello' }
- });
-
- const result = await client.feed.create('account', 'rec_123', {
- type: 'comment',
- body: 'Hello'
- });
-
- expect(fetchMock).toHaveBeenCalledWith(
- 'http://localhost:3000/api/v1/data/account/rec_123/feed',
- expect.objectContaining({
- method: 'POST',
- body: JSON.stringify({ type: 'comment', body: 'Hello' })
- })
- );
- expect(result.id).toBe('feed_1');
- });
-
- it('feed.update should PUT /api/v1/data/:object/:recordId/feed/:feedId', async () => {
- const { client, fetchMock } = createMockClient({
- success: true,
- data: { id: 'feed_1', type: 'comment', body: 'Updated' }
- });
-
- const result = await client.feed.update('account', 'rec_123', 'feed_1', {
- body: 'Updated'
- });
-
- expect(fetchMock).toHaveBeenCalledWith(
- 'http://localhost:3000/api/v1/data/account/rec_123/feed/feed_1',
- expect.objectContaining({
- method: 'PUT',
- body: JSON.stringify({ body: 'Updated' })
- })
- );
- expect(result.body).toBe('Updated');
- });
-
- it('feed.delete should DELETE /api/v1/data/:object/:recordId/feed/:feedId', async () => {
- const { client, fetchMock } = createMockClient({
- success: true,
- data: { feedId: 'feed_1' }
- });
-
- const result = await client.feed.delete('account', 'rec_123', 'feed_1');
-
- expect(fetchMock).toHaveBeenCalledWith(
- 'http://localhost:3000/api/v1/data/account/rec_123/feed/feed_1',
- expect.objectContaining({ method: 'DELETE' })
- );
- expect(result.feedId).toBe('feed_1');
- });
-
- // ==========================================
- // Reactions
- // ==========================================
-
- it('feed.addReaction should POST reactions endpoint', async () => {
- const { client, fetchMock } = createMockClient({
- success: true,
- data: { reactions: [{ emoji: '👍', count: 1 }] }
- });
-
- const result = await client.feed.addReaction('account', 'rec_123', 'feed_1', '👍');
-
- expect(fetchMock).toHaveBeenCalledWith(
- 'http://localhost:3000/api/v1/data/account/rec_123/feed/feed_1/reactions',
- expect.objectContaining({
- method: 'POST',
- body: JSON.stringify({ emoji: '👍' })
- })
- );
- expect(result.reactions).toHaveLength(1);
- });
-
- it('feed.removeReaction should DELETE reactions/:emoji endpoint', async () => {
- const { client, fetchMock } = createMockClient({
- success: true,
- data: { reactions: [] }
- });
-
- await client.feed.removeReaction('account', 'rec_123', 'feed_1', '👍');
-
- expect(fetchMock).toHaveBeenCalledWith(
- expect.stringContaining('/api/v1/data/account/rec_123/feed/feed_1/reactions/'),
- expect.objectContaining({ method: 'DELETE' })
- );
- });
-
- // ==========================================
- // Pin / Star
- // ==========================================
-
- it('feed.pin should POST pin endpoint', async () => {
- const { client, fetchMock } = createMockClient({
- success: true,
- data: { feedId: 'feed_1', pinned: true, pinnedAt: '2026-01-01T00:00:00Z' }
- });
-
- const result = await client.feed.pin('account', 'rec_123', 'feed_1');
-
- expect(fetchMock).toHaveBeenCalledWith(
- 'http://localhost:3000/api/v1/data/account/rec_123/feed/feed_1/pin',
- expect.objectContaining({ method: 'POST' })
- );
- expect(result.pinned).toBe(true);
- });
-
- it('feed.unpin should DELETE pin endpoint', async () => {
- const { client, fetchMock } = createMockClient({
- success: true,
- data: { feedId: 'feed_1', pinned: false }
- });
-
- const result = await client.feed.unpin('account', 'rec_123', 'feed_1');
-
- expect(fetchMock).toHaveBeenCalledWith(
- 'http://localhost:3000/api/v1/data/account/rec_123/feed/feed_1/pin',
- expect.objectContaining({ method: 'DELETE' })
- );
- expect(result.pinned).toBe(false);
- });
-
- it('feed.star should POST star endpoint', async () => {
- const { client, fetchMock } = createMockClient({
- success: true,
- data: { feedId: 'feed_1', starred: true, starredAt: '2026-01-01T00:00:00Z' }
- });
-
- const result = await client.feed.star('account', 'rec_123', 'feed_1');
-
- expect(fetchMock).toHaveBeenCalledWith(
- 'http://localhost:3000/api/v1/data/account/rec_123/feed/feed_1/star',
- expect.objectContaining({ method: 'POST' })
- );
- expect(result.starred).toBe(true);
- });
-
- it('feed.unstar should DELETE star endpoint', async () => {
- const { client, fetchMock } = createMockClient({
- success: true,
- data: { feedId: 'feed_1', starred: false }
- });
-
- const result = await client.feed.unstar('account', 'rec_123', 'feed_1');
-
- expect(fetchMock).toHaveBeenCalledWith(
- 'http://localhost:3000/api/v1/data/account/rec_123/feed/feed_1/star',
- expect.objectContaining({ method: 'DELETE' })
- );
- expect(result.starred).toBe(false);
- });
-
- // ==========================================
- // Search & Changelog
- // ==========================================
-
- it('feed.search should GET search endpoint with query params', async () => {
- const { client, fetchMock } = createMockClient({
- success: true,
- data: { items: [], total: 0, hasMore: false }
- });
-
- await client.feed.search('account', 'rec_123', 'follow up', { limit: 10 });
-
- expect(fetchMock).toHaveBeenCalledWith(
- expect.stringContaining('/api/v1/data/account/rec_123/feed/search?query=follow+up'),
- expect.any(Object)
- );
- });
-
- it('feed.getChangelog should GET changelog endpoint', async () => {
- const { client, fetchMock } = createMockClient({
- success: true,
- data: { entries: [], total: 0, hasMore: false }
- });
-
- await client.feed.getChangelog('account', 'rec_123', { field: 'status' });
-
- expect(fetchMock).toHaveBeenCalledWith(
- 'http://localhost:3000/api/v1/data/account/rec_123/changelog?field=status',
- expect.any(Object)
- );
- });
-
- // ==========================================
- // Subscriptions
- // ==========================================
-
- it('feed.subscribe should POST subscribe endpoint', async () => {
- const { client, fetchMock } = createMockClient({
- success: true,
- data: { object: 'account', recordId: 'rec_123', events: ['all'], channels: ['in_app'] }
- });
-
- const result = await client.feed.subscribe('account', 'rec_123', {
- events: ['comment', 'field_change'],
- channels: ['in_app', 'email']
- });
-
- expect(fetchMock).toHaveBeenCalledWith(
- 'http://localhost:3000/api/v1/data/account/rec_123/subscribe',
- expect.objectContaining({
- method: 'POST',
- body: JSON.stringify({
- events: ['comment', 'field_change'],
- channels: ['in_app', 'email']
- })
- })
- );
- expect(result.object).toBe('account');
- });
-
- it('feed.unsubscribe should DELETE subscribe endpoint', async () => {
- const { client, fetchMock } = createMockClient({
- success: true,
- data: { object: 'account', recordId: 'rec_123', unsubscribed: true }
- });
-
- const result = await client.feed.unsubscribe('account', 'rec_123');
-
- expect(fetchMock).toHaveBeenCalledWith(
- 'http://localhost:3000/api/v1/data/account/rec_123/subscribe',
- expect.objectContaining({ method: 'DELETE' })
- );
- expect(result.unsubscribed).toBe(true);
- });
-});
diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts
index db88be760a..5af5fd223b 100644
--- a/packages/client/src/index.ts
+++ b/packages/client/src/index.ts
@@ -66,20 +66,6 @@ import {
GetTranslationsResponse,
GetFieldLabelsResponse,
RegisterRequest,
- GetFeedResponse,
- CreateFeedItemResponse,
- UpdateFeedItemResponse,
- DeleteFeedItemResponse,
- AddReactionResponse,
- RemoveReactionResponse,
- PinFeedItemResponse,
- UnpinFeedItemResponse,
- StarFeedItemResponse,
- UnstarFeedItemResponse,
- SearchFeedResponse,
- GetChangelogResponse,
- SubscribeResponse,
- UnsubscribeResponse,
WellKnownCapabilities,
ApiRoutes,
ImportRequest,
@@ -2767,188 +2753,6 @@ export class ObjectStackClient {
}
};
- /**
- * Feed / Chatter Services
- *
- * Provides access to the activity timeline (comments, field changes, tasks),
- * emoji reactions, pin/star, search, changelog, and record subscriptions.
- * Base path: /api/data/{object}/{recordId}/feed
- */
- feed = {
- /**
- * List feed items for a record
- */
- list: async (object: string, recordId: string, options?: { type?: string; limit?: number; cursor?: string }): Promise => {
- const route = this.getRoute('data');
- const params = new URLSearchParams();
- if (options?.type) params.set('type', options.type);
- if (options?.limit) params.set('limit', String(options.limit));
- if (options?.cursor) params.set('cursor', options.cursor);
- const qs = params.toString();
- const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed${qs ? `?${qs}` : ''}`);
- return this.unwrapResponse(res);
- },
-
- /**
- * Create a new feed item (comment, note, task, etc.)
- */
- create: async (object: string, recordId: string, data: { type: string; body?: string; mentions?: any[]; parentId?: string; visibility?: string }): Promise => {
- const route = this.getRoute('data');
- const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed`, {
- method: 'POST',
- body: JSON.stringify(data)
- });
- return this.unwrapResponse(res);
- },
-
- /**
- * Update an existing feed item
- */
- update: async (object: string, recordId: string, feedId: string, data: { body?: string; mentions?: any[]; visibility?: string }): Promise => {
- const route = this.getRoute('data');
- const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/${encodeURIComponent(feedId)}`, {
- method: 'PUT',
- body: JSON.stringify(data)
- });
- return this.unwrapResponse(res);
- },
-
- /**
- * Delete a feed item
- */
- delete: async (object: string, recordId: string, feedId: string): Promise => {
- const route = this.getRoute('data');
- const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/${encodeURIComponent(feedId)}`, {
- method: 'DELETE'
- });
- return this.unwrapResponse(res);
- },
-
- /**
- * Add an emoji reaction to a feed item
- */
- addReaction: async (object: string, recordId: string, feedId: string, emoji: string): Promise => {
- const route = this.getRoute('data');
- const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/${encodeURIComponent(feedId)}/reactions`, {
- method: 'POST',
- body: JSON.stringify({ emoji })
- });
- return this.unwrapResponse(res);
- },
-
- /**
- * Remove an emoji reaction from a feed item
- */
- removeReaction: async (object: string, recordId: string, feedId: string, emoji: string): Promise => {
- const route = this.getRoute('data');
- const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/${encodeURIComponent(feedId)}/reactions/${encodeURIComponent(emoji)}`, {
- method: 'DELETE'
- });
- return this.unwrapResponse(res);
- },
-
- /**
- * Pin a feed item to the top of the timeline
- */
- pin: async (object: string, recordId: string, feedId: string): Promise => {
- const route = this.getRoute('data');
- const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/${encodeURIComponent(feedId)}/pin`, {
- method: 'POST'
- });
- return this.unwrapResponse(res);
- },
-
- /**
- * Unpin a feed item
- */
- unpin: async (object: string, recordId: string, feedId: string): Promise => {
- const route = this.getRoute('data');
- const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/${encodeURIComponent(feedId)}/pin`, {
- method: 'DELETE'
- });
- return this.unwrapResponse(res);
- },
-
- /**
- * Star (bookmark) a feed item
- */
- star: async (object: string, recordId: string, feedId: string): Promise => {
- const route = this.getRoute('data');
- const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/${encodeURIComponent(feedId)}/star`, {
- method: 'POST'
- });
- return this.unwrapResponse(res);
- },
-
- /**
- * Unstar a feed item
- */
- unstar: async (object: string, recordId: string, feedId: string): Promise => {
- const route = this.getRoute('data');
- const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/${encodeURIComponent(feedId)}/star`, {
- method: 'DELETE'
- });
- return this.unwrapResponse(res);
- },
-
- /**
- * Search feed items
- */
- search: async (object: string, recordId: string, query: string, options?: { type?: string; actorId?: string; dateFrom?: string; dateTo?: string; limit?: number; cursor?: string }): Promise => {
- const route = this.getRoute('data');
- const params = new URLSearchParams();
- params.set('query', query);
- if (options?.type) params.set('type', options.type);
- if (options?.actorId) params.set('actorId', options.actorId);
- if (options?.dateFrom) params.set('dateFrom', options.dateFrom);
- if (options?.dateTo) params.set('dateTo', options.dateTo);
- if (options?.limit) params.set('limit', String(options.limit));
- if (options?.cursor) params.set('cursor', options.cursor);
- const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/search?${params.toString()}`);
- return this.unwrapResponse(res);
- },
-
- /**
- * Get field-level changelog for a record
- */
- getChangelog: async (object: string, recordId: string, options?: { field?: string; actorId?: string; dateFrom?: string; dateTo?: string; limit?: number; cursor?: string }): Promise => {
- const route = this.getRoute('data');
- const params = new URLSearchParams();
- if (options?.field) params.set('field', options.field);
- if (options?.actorId) params.set('actorId', options.actorId);
- if (options?.dateFrom) params.set('dateFrom', options.dateFrom);
- if (options?.dateTo) params.set('dateTo', options.dateTo);
- if (options?.limit) params.set('limit', String(options.limit));
- if (options?.cursor) params.set('cursor', options.cursor);
- const qs = params.toString();
- const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/changelog${qs ? `?${qs}` : ''}`);
- return this.unwrapResponse(res);
- },
-
- /**
- * Subscribe to record notifications
- */
- subscribe: async (object: string, recordId: string, options?: { events?: string[]; channels?: string[] }): Promise => {
- const route = this.getRoute('data');
- const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/subscribe`, {
- method: 'POST',
- body: JSON.stringify(options || {})
- });
- return this.unwrapResponse(res);
- },
-
- /**
- * Unsubscribe from record notifications
- */
- unsubscribe: async (object: string, recordId: string): Promise => {
- const route = this.getRoute('data');
- const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/subscribe`, {
- method: 'DELETE'
- });
- return this.unwrapResponse(res);
- },
- };
-
/**
* Data Operations
*/
@@ -3398,6 +3202,10 @@ export class ObjectStackClient {
notifications: '/api/v1/notifications',
ai: '/api/v1/ai',
i18n: '/api/v1/i18n',
+ // NOTE: the `feed` route constant is retained only to satisfy the
+ // discovery `ApiRoutes` type (`routes.feed`), which is a separate
+ // follow-up cleanup. The feed SDK accessor + backend were retired
+ // (ADR-0052 §5); no client code consumes this route.
feed: '/api/v1/feed',
graphql: '/graphql',
};
@@ -3796,20 +3604,6 @@ export type {
GetFieldLabelsResponse,
RegisterRequest,
RefreshTokenRequest,
- GetFeedResponse,
- CreateFeedItemResponse,
- UpdateFeedItemResponse,
- DeleteFeedItemResponse,
- AddReactionResponse,
- RemoveReactionResponse,
- PinFeedItemResponse,
- UnpinFeedItemResponse,
- StarFeedItemResponse,
- UnstarFeedItemResponse,
- SearchFeedResponse,
- GetChangelogResponse,
- SubscribeResponse,
- UnsubscribeResponse,
WellKnownCapabilities,
GetAuthConfigResponse,
AuthProviderInfo,
diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts
index aa92cbc714..82564626a5 100644
--- a/packages/metadata-protocol/src/protocol.ts
+++ b/packages/metadata-protocol/src/protocol.ts
@@ -16,7 +16,6 @@ import type {
} from '@objectstack/spec/api';
import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities } from '@objectstack/spec/api';
import { readServiceSelfInfo } from '@objectstack/spec/api';
-import type { IFeedService } from '@objectstack/spec/contracts';
import { parseFilterAST, isFilterAST } from '@objectstack/spec/data';
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared';
import { type FormView, isAggregatedViewContainer } from '@objectstack/spec/ui';
@@ -836,7 +835,6 @@ export type MetadataAuthoringGate = (ctx: MetadataAuthoringGateContext) => void
export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
private engine: MetadataHostEngine;
private getServicesRegistry?: () => Map;
- private getFeedService?: () => IFeedService | undefined;
/**
* Project scope applied to sys_metadata reads/writes. When undefined
* (single-kernel deployments), rows land in / come from the
@@ -892,12 +890,10 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
constructor(
engine: IDataEngine,
getServicesRegistry?: () => Map,
- getFeedService?: () => IFeedService | undefined,
environmentId?: string,
) {
this.engine = engine as MetadataHostEngine;
this.getServicesRegistry = getServicesRegistry;
- this.getFeedService = getFeedService;
this.environmentId = environmentId;
}
@@ -1176,14 +1172,6 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
return this.environmentId;
}
- private requireFeedService(): IFeedService {
- const svc = this.getFeedService?.();
- if (!svc) {
- throw new Error('Feed service not available. Install and register service-feed to enable feed operations.');
- }
- return svc;
- }
-
async getDiscovery() {
// Get registered services from kernel if available
const registeredServices = this.getServicesRegistry ? this.getServicesRegistry() : new Map();
@@ -6313,157 +6301,6 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
return { references: out };
}
- // ==========================================
- // Feed Operations
- // ==========================================
-
- async listFeed(request: any): Promise {
- const svc = this.requireFeedService();
- const result = await svc.listFeed({
- object: request.object,
- recordId: request.recordId,
- filter: request.type,
- limit: request.limit,
- cursor: request.cursor,
- });
- return { success: true, data: result };
- }
-
- async createFeedItem(request: any): Promise {
- const svc = this.requireFeedService();
- const item = await svc.createFeedItem({
- object: request.object,
- recordId: request.recordId,
- type: request.type,
- actor: { type: 'user', id: 'current_user' },
- body: request.body,
- mentions: request.mentions,
- parentId: request.parentId,
- visibility: request.visibility,
- });
- return { success: true, data: item };
- }
-
- async updateFeedItem(request: any): Promise {
- const svc = this.requireFeedService();
- const item = await svc.updateFeedItem(request.feedId, {
- body: request.body,
- mentions: request.mentions,
- visibility: request.visibility,
- });
- return { success: true, data: item };
- }
-
- async deleteFeedItem(request: any): Promise {
- const svc = this.requireFeedService();
- await svc.deleteFeedItem(request.feedId);
- return { success: true, data: { feedId: request.feedId } };
- }
-
- async addReaction(request: any): Promise {
- const svc = this.requireFeedService();
- const reactions = await svc.addReaction(request.feedId, request.emoji, 'current_user');
- return { success: true, data: { reactions } };
- }
-
- async removeReaction(request: any): Promise {
- const svc = this.requireFeedService();
- const reactions = await svc.removeReaction(request.feedId, request.emoji, 'current_user');
- return { success: true, data: { reactions } };
- }
-
- async pinFeedItem(request: any): Promise {
- const svc = this.requireFeedService();
- const item = await svc.getFeedItem(request.feedId);
- if (!item) throw new Error(`Feed item ${request.feedId} not found`);
- // IFeedService doesn't have dedicated pin/unpin — use updateFeedItem to persist pin state
- await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
- return { success: true, data: { feedId: request.feedId, pinned: true, pinnedAt: new Date().toISOString() } };
- }
-
- async unpinFeedItem(request: any): Promise {
- const svc = this.requireFeedService();
- const item = await svc.getFeedItem(request.feedId);
- if (!item) throw new Error(`Feed item ${request.feedId} not found`);
- await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
- return { success: true, data: { feedId: request.feedId, pinned: false } };
- }
-
- async starFeedItem(request: any): Promise {
- const svc = this.requireFeedService();
- const item = await svc.getFeedItem(request.feedId);
- if (!item) throw new Error(`Feed item ${request.feedId} not found`);
- // IFeedService doesn't have dedicated star/unstar — verify item exists then return state
- await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
- return { success: true, data: { feedId: request.feedId, starred: true, starredAt: new Date().toISOString() } };
- }
-
- async unstarFeedItem(request: any): Promise {
- const svc = this.requireFeedService();
- const item = await svc.getFeedItem(request.feedId);
- if (!item) throw new Error(`Feed item ${request.feedId} not found`);
- await svc.updateFeedItem(request.feedId, { visibility: item.visibility });
- return { success: true, data: { feedId: request.feedId, starred: false } };
- }
-
- async searchFeed(request: any): Promise {
- const svc = this.requireFeedService();
- // Search delegates to listFeed with filter since IFeedService doesn't have a dedicated search
- const result = await svc.listFeed({
- object: request.object,
- recordId: request.recordId,
- filter: request.type,
- limit: request.limit,
- cursor: request.cursor,
- });
- // Filter by query text in body
- const queryLower = (request.query || '').toLowerCase();
- const filtered = result.items.filter((item: any) =>
- item.body?.toLowerCase().includes(queryLower)
- );
- return { success: true, data: { items: filtered, total: filtered.length, hasMore: false } };
- }
-
- async getChangelog(request: any): Promise {
- const svc = this.requireFeedService();
- // Changelog retrieves field_change type feed items
- const result = await svc.listFeed({
- object: request.object,
- recordId: request.recordId,
- filter: 'changes_only',
- limit: request.limit,
- cursor: request.cursor,
- });
- const entries = result.items.map((item: any) => ({
- id: item.id,
- object: item.object,
- recordId: item.recordId,
- actor: item.actor,
- changes: item.changes || [],
- timestamp: item.createdAt,
- source: item.source,
- }));
- return { success: true, data: { entries, total: result.total, nextCursor: result.nextCursor, hasMore: result.hasMore } };
- }
-
- async feedSubscribe(request: any): Promise {
- const svc = this.requireFeedService();
- const subscription = await svc.subscribe({
- object: request.object,
- recordId: request.recordId,
- userId: 'current_user',
- events: request.events,
- channels: request.channels,
- });
- return { success: true, data: subscription };
- }
-
- async feedUnsubscribe(request: any): Promise {
- const svc = this.requireFeedService();
- const unsubscribed = await svc.unsubscribe(request.object, request.recordId, 'current_user');
- return { success: true, data: { object: request.object, recordId: request.recordId, unsubscribed } };
- }
-
/**
* Install a package from a manifest — the single canonical write primitive
* for the package subsystem (ADR-0033 consolidation).
diff --git a/packages/objectql/src/overlay-precedence.test.ts b/packages/objectql/src/overlay-precedence.test.ts
index 66cdc3ee11..2ebf46405b 100644
--- a/packages/objectql/src/overlay-precedence.test.ts
+++ b/packages/objectql/src/overlay-precedence.test.ts
@@ -82,7 +82,6 @@ function makeProtocol(opts: { environmentId?: string } = {}) {
const protocol = new ObjectStackProtocolImplementation(
mockEngine,
undefined, // getServicesRegistry
- undefined, // getFeedService
opts.environmentId,
);
return { protocol, mockEngine, registry };
diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts
index 6d6235bbc0..e66abeb14f 100644
--- a/packages/objectql/src/plugin.ts
+++ b/packages/objectql/src/plugin.ts
@@ -230,7 +230,6 @@ export class ObjectQLPlugin implements Plugin {
const protocolShim = new ObjectStackProtocolImplementation(
this.ql,
() => ctx.getServices ? ctx.getServices() : new Map(),
- undefined,
this.environmentId,
);
diff --git a/packages/objectql/src/protocol-feed.test.ts b/packages/objectql/src/protocol-feed.test.ts
deleted file mode 100644
index e24bd2f27c..0000000000
--- a/packages/objectql/src/protocol-feed.test.ts
+++ /dev/null
@@ -1,303 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
-import { ObjectQL } from './engine.js';
-import type { IFeedService } from '@objectstack/spec/contracts';
-
-/**
- * Mock IFeedService for testing feed route handlers.
- */
-function createMockFeedService(): IFeedService {
- return {
- listFeed: vi.fn().mockResolvedValue({
- items: [{ id: 'feed_1', type: 'comment', body: 'Hello world', createdAt: '2026-01-01T00:00:00Z' }],
- total: 1,
- hasMore: false,
- }),
- createFeedItem: vi.fn().mockResolvedValue({
- id: 'feed_new',
- type: 'comment',
- body: 'New comment',
- createdAt: '2026-01-01T00:00:00Z',
- }),
- updateFeedItem: vi.fn().mockResolvedValue({
- id: 'feed_1',
- type: 'comment',
- body: 'Updated comment',
- createdAt: '2026-01-01T00:00:00Z',
- }),
- deleteFeedItem: vi.fn().mockResolvedValue(undefined),
- getFeedItem: vi.fn().mockResolvedValue({
- id: 'feed_1',
- type: 'comment',
- body: 'Hello world',
- createdAt: '2026-01-01T00:00:00Z',
- }),
- addReaction: vi.fn().mockResolvedValue([
- { emoji: '👍', users: ['current_user'], count: 1 },
- ]),
- removeReaction: vi.fn().mockResolvedValue([]),
- subscribe: vi.fn().mockResolvedValue({
- id: 'sub_1',
- object: 'account',
- recordId: 'rec_123',
- userId: 'current_user',
- events: ['all'],
- channels: ['in_app'],
- }),
- unsubscribe: vi.fn().mockResolvedValue(true),
- getSubscription: vi.fn().mockResolvedValue(null),
- };
-}
-
-describe('ObjectStackProtocolImplementation - Feed Operations', () => {
- let protocol: ObjectStackProtocolImplementation;
- let engine: ObjectQL;
- let feedService: IFeedService;
-
- beforeEach(() => {
- engine = new ObjectQL();
- feedService = createMockFeedService();
- protocol = new ObjectStackProtocolImplementation(engine, undefined, () => feedService);
- });
-
- // ==========================================
- // Discovery
- // ==========================================
-
- it('should show feed service as unavailable when not registered', async () => {
- const protocolNoFeed = new ObjectStackProtocolImplementation(engine);
- const discovery = await protocolNoFeed.getDiscovery();
-
- expect(discovery.services.feed).toBeDefined();
- expect(discovery.services.feed.enabled).toBe(false);
- expect(discovery.services.feed.status).toBe('unavailable');
- });
-
- it('should show feed service as available when registered', async () => {
- const mockServices = new Map();
- mockServices.set('feed', {});
- const protocolWithFeed = new ObjectStackProtocolImplementation(engine, () => mockServices, () => feedService);
- const discovery = await protocolWithFeed.getDiscovery();
-
- expect(discovery.services.feed).toBeDefined();
- expect(discovery.services.feed.enabled).toBe(true);
- expect(discovery.services.feed.status).toBe('available');
- });
-
- // ==========================================
- // Feed CRUD
- // ==========================================
-
- it('listFeed should delegate to feedService.listFeed', async () => {
- const result = await protocol.listFeed({ object: 'account', recordId: 'rec_123' });
-
- expect(result.success).toBe(true);
- expect(result.data.items).toHaveLength(1);
- expect(feedService.listFeed).toHaveBeenCalledWith(
- expect.objectContaining({ object: 'account', recordId: 'rec_123' })
- );
- });
-
- it('createFeedItem should delegate to feedService.createFeedItem', async () => {
- const result = await protocol.createFeedItem({
- object: 'account',
- recordId: 'rec_123',
- type: 'comment',
- body: 'New comment',
- });
-
- expect(result.success).toBe(true);
- expect(result.data.id).toBe('feed_new');
- expect(feedService.createFeedItem).toHaveBeenCalledWith(
- expect.objectContaining({ object: 'account', recordId: 'rec_123', type: 'comment', body: 'New comment' })
- );
- });
-
- it('updateFeedItem should delegate to feedService.updateFeedItem', async () => {
- const result = await protocol.updateFeedItem({
- object: 'account',
- recordId: 'rec_123',
- feedId: 'feed_1',
- body: 'Updated',
- });
-
- expect(result.success).toBe(true);
- expect(result.data.body).toBe('Updated comment');
- expect(feedService.updateFeedItem).toHaveBeenCalledWith('feed_1', expect.objectContaining({ body: 'Updated' }));
- });
-
- it('deleteFeedItem should delegate to feedService.deleteFeedItem', async () => {
- const result = await protocol.deleteFeedItem({
- object: 'account',
- recordId: 'rec_123',
- feedId: 'feed_1',
- });
-
- expect(result.success).toBe(true);
- expect(result.data.feedId).toBe('feed_1');
- expect(feedService.deleteFeedItem).toHaveBeenCalledWith('feed_1');
- });
-
- // ==========================================
- // Reactions
- // ==========================================
-
- it('addReaction should delegate to feedService.addReaction', async () => {
- const result = await protocol.addReaction({
- object: 'account',
- recordId: 'rec_123',
- feedId: 'feed_1',
- emoji: '👍',
- });
-
- expect(result.success).toBe(true);
- expect(result.data.reactions).toHaveLength(1);
- expect(feedService.addReaction).toHaveBeenCalledWith('feed_1', '👍', 'current_user');
- });
-
- it('removeReaction should delegate to feedService.removeReaction', async () => {
- const result = await protocol.removeReaction({
- object: 'account',
- recordId: 'rec_123',
- feedId: 'feed_1',
- emoji: '👍',
- });
-
- expect(result.success).toBe(true);
- expect(result.data.reactions).toHaveLength(0);
- expect(feedService.removeReaction).toHaveBeenCalledWith('feed_1', '👍', 'current_user');
- });
-
- // ==========================================
- // Pin / Star
- // ==========================================
-
- it('pinFeedItem should verify item exists and return pinned status', async () => {
- const result = await protocol.pinFeedItem({
- object: 'account',
- recordId: 'rec_123',
- feedId: 'feed_1',
- });
-
- expect(result.success).toBe(true);
- expect(result.data.feedId).toBe('feed_1');
- expect(result.data.pinned).toBe(true);
- expect(result.data.pinnedAt).toBeDefined();
- });
-
- it('unpinFeedItem should verify item exists and return unpinned status', async () => {
- const result = await protocol.unpinFeedItem({
- object: 'account',
- recordId: 'rec_123',
- feedId: 'feed_1',
- });
-
- expect(result.success).toBe(true);
- expect(result.data.feedId).toBe('feed_1');
- expect(result.data.pinned).toBe(false);
- });
-
- it('starFeedItem should verify item exists and return starred status', async () => {
- const result = await protocol.starFeedItem({
- object: 'account',
- recordId: 'rec_123',
- feedId: 'feed_1',
- });
-
- expect(result.success).toBe(true);
- expect(result.data.feedId).toBe('feed_1');
- expect(result.data.starred).toBe(true);
- expect(result.data.starredAt).toBeDefined();
- });
-
- it('unstarFeedItem should verify item exists and return unstarred status', async () => {
- const result = await protocol.unstarFeedItem({
- object: 'account',
- recordId: 'rec_123',
- feedId: 'feed_1',
- });
-
- expect(result.success).toBe(true);
- expect(result.data.feedId).toBe('feed_1');
- expect(result.data.starred).toBe(false);
- });
-
- // ==========================================
- // Search & Changelog
- // ==========================================
-
- it('searchFeed should filter items by query text', async () => {
- const result = await protocol.searchFeed({
- object: 'account',
- recordId: 'rec_123',
- query: 'hello',
- });
-
- expect(result.success).toBe(true);
- expect(result.data.items).toHaveLength(1);
- expect(result.data.hasMore).toBe(false);
- });
-
- it('getChangelog should return field change entries', async () => {
- const result = await protocol.getChangelog({
- object: 'account',
- recordId: 'rec_123',
- });
-
- expect(result.success).toBe(true);
- expect(result.data.entries).toBeDefined();
- expect(feedService.listFeed).toHaveBeenCalledWith(
- expect.objectContaining({ filter: 'changes_only' })
- );
- });
-
- // ==========================================
- // Subscriptions
- // ==========================================
-
- it('feedSubscribe should delegate to feedService.subscribe', async () => {
- const result = await protocol.feedSubscribe({
- object: 'account',
- recordId: 'rec_123',
- events: ['all'],
- channels: ['in_app'],
- });
-
- expect(result.success).toBe(true);
- expect(result.data.object).toBe('account');
- expect(feedService.subscribe).toHaveBeenCalledWith(
- expect.objectContaining({ object: 'account', recordId: 'rec_123' })
- );
- });
-
- it('feedUnsubscribe should delegate to feedService.unsubscribe', async () => {
- const result = await protocol.feedUnsubscribe({
- object: 'account',
- recordId: 'rec_123',
- });
-
- expect(result.success).toBe(true);
- expect(result.data.unsubscribed).toBe(true);
- expect(feedService.unsubscribe).toHaveBeenCalledWith('account', 'rec_123', 'current_user');
- });
-
- // ==========================================
- // Error handling
- // ==========================================
-
- it('should throw when feed service is not available', async () => {
- const protocolNoFeed = new ObjectStackProtocolImplementation(engine);
-
- await expect(protocolNoFeed.listFeed({ object: 'a', recordId: 'b' }))
- .rejects.toThrow('Feed service not available');
- });
-
- it('pinFeedItem should throw when feed item not found', async () => {
- (feedService.getFeedItem as any).mockResolvedValue(null);
-
- await expect(protocol.pinFeedItem({ object: 'a', recordId: 'b', feedId: 'nonexistent' }))
- .rejects.toThrow('Feed item nonexistent not found');
- });
-});
diff --git a/packages/objectql/src/protocol-lock-enforcement.test.ts b/packages/objectql/src/protocol-lock-enforcement.test.ts
index 91405785fb..1ce0733c76 100644
--- a/packages/objectql/src/protocol-lock-enforcement.test.ts
+++ b/packages/objectql/src/protocol-lock-enforcement.test.ts
@@ -40,7 +40,6 @@ function makeProtocol(opts: { environmentId?: string } = {}) {
const protocol = new ObjectStackProtocolImplementation(
mockEngine,
undefined,
- undefined,
opts.environmentId ?? 'env_prod',
);
return { protocol, mockEngine, registry };
@@ -173,7 +172,7 @@ describe('ADR-0010 L3 lock enforcement — single-kernel bypass', () => {
};
// No environmentId — single-kernel / control plane.
const protocol = new ObjectStackProtocolImplementation(
- mockEngine, undefined, undefined, undefined,
+ mockEngine, undefined, undefined,
);
seedLockedArtifact(registry, 'view', 'case_grid', 'full');
diff --git a/packages/objectql/src/protocol-meta-types-rich.test.ts b/packages/objectql/src/protocol-meta-types-rich.test.ts
index dfa3aa1cea..a493038972 100644
--- a/packages/objectql/src/protocol-meta-types-rich.test.ts
+++ b/packages/objectql/src/protocol-meta-types-rich.test.ts
@@ -113,7 +113,7 @@ describe('ObjectStackProtocolImplementation - getMetaTypes rich response', () =>
it('saveMetaItem honours the env-elevated allow list', async () => {
// Scoped (project) protocol — overlay gate applies.
- const scoped = new ObjectStackProtocolImplementation(mockEngine, undefined, undefined, 'env_alpha');
+ const scoped = new ObjectStackProtocolImplementation(mockEngine, undefined, 'env_alpha');
mockEngine.findOne.mockResolvedValue(null);
// Without env var: `agent` writes blocked — the one remaining
diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts
index c2ba396fbe..26c11e0a42 100644
--- a/packages/objectql/src/protocol-meta.test.ts
+++ b/packages/objectql/src/protocol-meta.test.ts
@@ -1165,7 +1165,6 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
scoped = new ObjectStackProtocolImplementation(
mockEngine,
undefined,
- undefined,
'env_alpha',
);
});
diff --git a/packages/objectql/src/protocol-registry-shadow.test.ts b/packages/objectql/src/protocol-registry-shadow.test.ts
index c761a4f584..8fec457e30 100644
--- a/packages/objectql/src/protocol-registry-shadow.test.ts
+++ b/packages/objectql/src/protocol-registry-shadow.test.ts
@@ -262,7 +262,7 @@ describe('registry shadow — scoped-kernel lock enforcement is shadow-immune',
delete: async () => ({ deleted: 1 }),
};
const protocol = new ObjectStackProtocolImplementation(
- mockEngine, undefined, undefined, 'env_prod',
+ mockEngine, undefined, 'env_prod',
);
await expect(protocol.saveMetaItem({
diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json
index 8dcf44a72a..46e61d9d01 100644
--- a/packages/spec/api-surface.json
+++ b/packages/spec/api-surface.json
@@ -299,16 +299,9 @@
"ExternalTableSchema (const)",
"FIELD_GROUP_SYSTEM_FIELDS (const)",
"FILTER_OPERATORS (const)",
- "FeedActor (type)",
- "FeedActorSchema (const)",
"FeedFilterMode (type)",
- "FeedItem (type)",
- "FeedItemSchema (const)",
"FeedItemType (type)",
- "FeedVisibility (type)",
"Field (type)",
- "FieldChangeEntry (type)",
- "FieldChangeEntrySchema (const)",
"FieldGroupCollapse (type)",
"FieldGroupSection (interface)",
"FieldInput (type)",
@@ -363,8 +356,6 @@
"Mapping (type)",
"MappingInput (type)",
"MappingSchema (const)",
- "Mention (type)",
- "MentionSchema (const)",
"Metric (type)",
"MetricSchema (const)",
"NoSQLDataTypeMapping (type)",
@@ -385,7 +376,6 @@
"NoSQLTransactionOptionsSchema (const)",
"NormalizedFilter (type)",
"NormalizedFilterSchema (const)",
- "NotificationChannel (type)",
"ObjectAccessConfig (type)",
"ObjectAccessConfigSchema (const)",
"ObjectCapabilities (type)",
@@ -421,13 +411,9 @@
"QuerySchema (const)",
"RECORD_SURFACE_PAGE_THRESHOLD (const)",
"RangeOperatorSchema (const)",
- "Reaction (type)",
- "ReactionSchema (const)",
"RecordFlow (type)",
"RecordFlowContainer (type)",
"RecordFlowSurface (interface)",
- "RecordSubscription (type)",
- "RecordSubscriptionSchema (const)",
"RecordSurface (type)",
"RecordSurfaceOptions (interface)",
"RecordSurfaceViewport (type)",
@@ -494,7 +480,6 @@
"StateMachineValidation (type)",
"StateMachineValidationSchema (const)",
"StringOperatorSchema (const)",
- "SubscriptionEventType (type)",
"TITLE_ELIGIBLE (const)",
"TITLE_ELIGIBLE_TYPES (const)",
"TITLE_INELIGIBLE_TYPES (const)",
@@ -2131,10 +2116,6 @@
"./api": [
"AckMessage (type)",
"AckMessageSchema (const)",
- "AddReactionRequest (type)",
- "AddReactionRequestSchema (const)",
- "AddReactionResponse (type)",
- "AddReactionResponseSchema (const)",
"AiInsightsRequest (type)",
"AiInsightsRequestSchema (const)",
"AiInsightsResponse (type)",
@@ -2264,8 +2245,6 @@
"CacheInvalidationTarget (type)",
"Callback (type)",
"CallbackSchema (const)",
- "ChangelogEntry (type)",
- "ChangelogEntrySchema (const)",
"CheckPermissionRequest (type)",
"CheckPermissionRequestSchema (const)",
"CheckPermissionResponse (type)",
@@ -2289,10 +2268,6 @@
"CreateExportJobRequestSchema (const)",
"CreateExportJobResponse (type)",
"CreateExportJobResponseSchema (const)",
- "CreateFeedItemRequest (type)",
- "CreateFeedItemRequestSchema (const)",
- "CreateFeedItemResponse (type)",
- "CreateFeedItemResponseSchema (const)",
"CreateFlowRequest (type)",
"CreateFlowRequestSchema (const)",
"CreateFlowResponse (type)",
@@ -2347,10 +2322,6 @@
"DeleteDataRequestSchema (const)",
"DeleteDataResponse (type)",
"DeleteDataResponseSchema (const)",
- "DeleteFeedItemRequest (type)",
- "DeleteFeedItemRequestSchema (const)",
- "DeleteFeedItemResponse (type)",
- "DeleteFeedItemResponseSchema (const)",
"DeleteFlowRequest (type)",
"DeleteFlowRequestSchema (const)",
"DeleteFlowResponse (type)",
@@ -2453,16 +2424,6 @@
"FederationProvidesSchema (const)",
"FederationRequires (type)",
"FederationRequiresSchema (const)",
- "FeedApiContracts (const)",
- "FeedApiErrorCode (type)",
- "FeedItemPathParams (type)",
- "FeedItemPathParamsSchema (const)",
- "FeedListFilterType (const)",
- "FeedPathParams (type)",
- "FeedPathParamsSchema (const)",
- "FeedProtocol (interface)",
- "FeedUnsubscribeRequest (type)",
- "FeedUnsubscribeRequestSchema (const)",
"FieldError (type)",
"FieldErrorSchema (const)",
"FieldMappingEntry (type)",
@@ -2485,10 +2446,6 @@
"GetAnalyticsMetaRequestSchema (const)",
"GetAuthConfigResponse (type)",
"GetAuthConfigResponseSchema (const)",
- "GetChangelogRequest (type)",
- "GetChangelogRequestSchema (const)",
- "GetChangelogResponse (type)",
- "GetChangelogResponseSchema (const)",
"GetDataRequest (type)",
"GetDataRequestSchema (const)",
"GetDataResponse (type)",
@@ -2505,10 +2462,6 @@
"GetExportJobDownloadRequestSchema (const)",
"GetExportJobDownloadResponse (type)",
"GetExportJobDownloadResponseSchema (const)",
- "GetFeedRequest (type)",
- "GetFeedRequestSchema (const)",
- "GetFeedResponse (type)",
- "GetFeedResponseSchema (const)",
"GetFieldLabelsRequest (type)",
"GetFieldLabelsRequestSchema (const)",
"GetFieldLabelsResponse (type)",
@@ -2829,10 +2782,6 @@
"PackageUpgradeResponse (type)",
"PackageUpgradeResponseSchema (const)",
"PermissionProtocol (interface)",
- "PinFeedItemRequest (type)",
- "PinFeedItemRequestSchema (const)",
- "PinFeedItemResponse (type)",
- "PinFeedItemResponseSchema (const)",
"PingMessage (type)",
"PingMessageSchema (const)",
"PongMessage (type)",
@@ -2888,10 +2837,6 @@
"RegisterDeviceResponseSchema (const)",
"RegisterRequest (type)",
"RegisterRequestSchema (const)",
- "RemoveReactionRequest (type)",
- "RemoveReactionRequestSchema (const)",
- "RemoveReactionResponse (type)",
- "RemoveReactionResponseSchema (const)",
"RequestValidationConfig (type)",
"RequestValidationConfigInput (type)",
"RequestValidationConfigSchema (const)",
@@ -2949,10 +2894,6 @@
"ScheduledExport (type)",
"ScheduledExportSchema (const)",
"SchemaDefinition (type)",
- "SearchFeedRequest (type)",
- "SearchFeedRequestSchema (const)",
- "SearchFeedResponse (type)",
- "SearchFeedResponseSchema (const)",
"ServiceInfo (type)",
"ServiceInfoSchema (const)",
"ServiceSelfInfo (type)",
@@ -2977,20 +2918,12 @@
"SingleRecordResponseSchema (const)",
"StandardApiContracts (const)",
"StandardErrorCode (type)",
- "StarFeedItemRequest (type)",
- "StarFeedItemRequestSchema (const)",
- "StarFeedItemResponse (type)",
- "StarFeedItemResponseSchema (const)",
"StorageApiContracts (const)",
"SubgraphConfig (type)",
"SubgraphConfigInput (type)",
"SubgraphConfigSchema (const)",
"SubscribeMessage (type)",
"SubscribeMessageSchema (const)",
- "SubscribeRequest (type)",
- "SubscribeRequestSchema (const)",
- "SubscribeResponse (type)",
- "SubscribeResponseSchema (const)",
"Subscription (type)",
"SubscriptionEventSchema (const)",
"SubscriptionSchema (const)",
@@ -3013,32 +2946,18 @@
"UninstallPackageRequestSchema (const)",
"UninstallPackageResponse (type)",
"UninstallPackageResponseSchema (const)",
- "UnpinFeedItemRequest (type)",
- "UnpinFeedItemRequestSchema (const)",
- "UnpinFeedItemResponse (type)",
- "UnpinFeedItemResponseSchema (const)",
"UnregisterDeviceRequest (type)",
"UnregisterDeviceRequestSchema (const)",
"UnregisterDeviceResponse (type)",
"UnregisterDeviceResponseSchema (const)",
- "UnstarFeedItemRequest (type)",
- "UnstarFeedItemRequestSchema (const)",
- "UnstarFeedItemResponse (type)",
- "UnstarFeedItemResponseSchema (const)",
"UnsubscribeMessage (type)",
"UnsubscribeMessageSchema (const)",
"UnsubscribeRequest (type)",
"UnsubscribeRequestSchema (const)",
- "UnsubscribeResponse (type)",
- "UnsubscribeResponseSchema (const)",
"UpdateDataRequest (type)",
"UpdateDataRequestSchema (const)",
"UpdateDataResponse (type)",
"UpdateDataResponseSchema (const)",
- "UpdateFeedItemRequest (type)",
- "UpdateFeedItemRequestSchema (const)",
- "UpdateFeedItemResponse (type)",
- "UpdateFeedItemResponseSchema (const)",
"UpdateFlowRequest (type)",
"UpdateFlowRequestSchema (const)",
"UpdateFlowResponse (type)",
@@ -3564,7 +3483,6 @@
"CounterIncrOptions (interface)",
"CreateExportJobInput (interface)",
"CreateExportJobResult (interface)",
- "CreateFeedItemInput (interface)",
"CreateShareLinkInput (interface)",
"CryptoContext (interface)",
"CryptoHandle (interface)",
@@ -3581,7 +3499,6 @@
"ExecuteUpgradeInput (interface)",
"ExportJobDownload (interface)",
"ExportJobListResult (interface)",
- "FeedListResult (interface)",
"FinishReason (type)",
"GenerateDraftOpts (interface)",
"GenerateObjectOptions (interface)",
@@ -3613,7 +3530,6 @@
"IEmbedder (interface)",
"IExportService (interface)",
"IExternalDatasourceService (interface)",
- "IFeedService (interface)",
"IGraphQLService (interface)",
"IHierarchyScopeResolver (interface)",
"IHttpRequest (interface)",
@@ -3670,7 +3586,6 @@
"KnowledgeSearchOptions (interface)",
"LLMAdapter (interface)",
"ListExportJobsOptions (interface)",
- "ListFeedOptions (interface)",
"ListShareLinksFilter (interface)",
"LockAcquireOptions (interface)",
"LockHandle (interface)",
@@ -3758,7 +3673,6 @@
"StorageFileInfo (interface)",
"StorageUploadOptions (interface)",
"StrategyContext (interface)",
- "SubscribeInput (interface)",
"SubscribeOptions (interface)",
"SystemModelMessage (type)",
"TextStreamPart (type)",
@@ -3769,7 +3683,6 @@
"ToolSet (type)",
"TransportSendResult (interface)",
"Unsubscribe (type)",
- "UpdateFeedItemInput (interface)",
"UploadArtifactInput (interface)",
"UploadArtifactResult (interface)",
"UserModelMessage (type)",
diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json
index aad0c068b9..17685685b4 100644
--- a/packages/spec/json-schema.manifest.json
+++ b/packages/spec/json-schema.manifest.json
@@ -69,8 +69,6 @@
"ai/VectorStore",
"ai/VectorStoreProvider",
"api/AckMessage",
- "api/AddReactionRequest",
- "api/AddReactionResponse",
"api/AiInsightsRequest",
"api/AiInsightsResponse",
"api/AiNlqRequest",
@@ -133,7 +131,6 @@
"api/CacheInvalidationResponse",
"api/CacheInvalidationTarget",
"api/Callback",
- "api/ChangelogEntry",
"api/CheckPermissionRequest",
"api/CheckPermissionResponse",
"api/CodeGenerationTemplate",
@@ -146,8 +143,6 @@
"api/CreateDataResponse",
"api/CreateExportJobRequest",
"api/CreateExportJobResponse",
- "api/CreateFeedItemRequest",
- "api/CreateFeedItemResponse",
"api/CreateFlowRequest",
"api/CreateFlowResponse",
"api/CreateImportJobRequest",
@@ -168,8 +163,6 @@
"api/DeduplicationStrategy",
"api/DeleteDataRequest",
"api/DeleteDataResponse",
- "api/DeleteFeedItemRequest",
- "api/DeleteFeedItemResponse",
"api/DeleteFlowRequest",
"api/DeleteFlowResponse",
"api/DeleteManyDataRequest",
@@ -220,11 +213,6 @@
"api/FederationGateway",
"api/FederationProvides",
"api/FederationRequires",
- "api/FeedApiErrorCode",
- "api/FeedItemPathParams",
- "api/FeedListFilterType",
- "api/FeedPathParams",
- "api/FeedUnsubscribeRequest",
"api/FieldError",
"api/FieldMappingEntry",
"api/FileTypeValidation",
@@ -237,8 +225,6 @@
"api/GeneratedEndpoint",
"api/GetAnalyticsMetaRequest",
"api/GetAuthConfigResponse",
- "api/GetChangelogRequest",
- "api/GetChangelogResponse",
"api/GetDataRequest",
"api/GetDataResponse",
"api/GetDiscoveryRequest",
@@ -247,8 +233,6 @@
"api/GetEffectivePermissionsResponse",
"api/GetExportJobDownloadRequest",
"api/GetExportJobDownloadResponse",
- "api/GetFeedRequest",
- "api/GetFeedResponse",
"api/GetFieldLabelsRequest",
"api/GetFieldLabelsResponse",
"api/GetFlowRequest",
@@ -401,8 +385,6 @@
"api/PackageRollbackResponse",
"api/PackageUpgradeRequest",
"api/PackageUpgradeResponse",
- "api/PinFeedItemRequest",
- "api/PinFeedItemResponse",
"api/PingMessage",
"api/PongMessage",
"api/PresenceMessage",
@@ -431,8 +413,6 @@
"api/RegisterDeviceRequest",
"api/RegisterDeviceResponse",
"api/RegisterRequest",
- "api/RemoveReactionRequest",
- "api/RemoveReactionResponse",
"api/RequestValidationConfig",
"api/ResolveDependenciesRequest",
"api/ResolveDependenciesResponse",
@@ -459,8 +439,6 @@
"api/ScheduleExportResponse",
"api/ScheduledExport",
"api/SchemaDefinition",
- "api/SearchFeedRequest",
- "api/SearchFeedResponse",
"api/ServiceInfo",
"api/ServiceSelfInfo",
"api/ServiceStatus",
@@ -473,12 +451,8 @@
"api/SimplePresenceState",
"api/SingleRecordResponse",
"api/StandardErrorCode",
- "api/StarFeedItemRequest",
- "api/StarFeedItemResponse",
"api/SubgraphConfig",
"api/SubscribeMessage",
- "api/SubscribeRequest",
- "api/SubscribeResponse",
"api/Subscription",
"api/SubscriptionEvent",
"api/ToggleFlowRequest",
@@ -491,19 +465,12 @@
"api/UninstallPackageApiResponse",
"api/UninstallPackageRequest",
"api/UninstallPackageResponse",
- "api/UnpinFeedItemRequest",
- "api/UnpinFeedItemResponse",
"api/UnregisterDeviceRequest",
"api/UnregisterDeviceResponse",
- "api/UnstarFeedItemRequest",
- "api/UnstarFeedItemResponse",
"api/UnsubscribeMessage",
"api/UnsubscribeRequest",
- "api/UnsubscribeResponse",
"api/UpdateDataRequest",
"api/UpdateDataResponse",
- "api/UpdateFeedItemRequest",
- "api/UpdateFeedItemResponse",
"api/UpdateFlowRequest",
"api/UpdateFlowResponse",
"api/UpdateManyDataRequest",
@@ -778,13 +745,9 @@
"data/ExternalFieldMapping",
"data/ExternalLookup",
"data/ExternalTable",
- "data/FeedActor",
"data/FeedFilterMode",
- "data/FeedItem",
"data/FeedItemType",
- "data/FeedVisibility",
"data/Field",
- "data/FieldChangeEntry",
"data/FieldMapping",
"data/FieldNode",
"data/FieldReference",
@@ -807,7 +770,6 @@
"data/LifecycleClass",
"data/LocationCoordinates",
"data/Mapping",
- "data/Mention",
"data/Metric",
"data/ModeSchema",
"data/NoSQLDataTypeMapping",
@@ -818,7 +780,6 @@
"data/NoSQLOperationType",
"data/NoSQLQueryOptions",
"data/NoSQLTransactionOptions",
- "data/NotificationChannel",
"data/Object",
"data/ObjectAccessConfig",
"data/ObjectCapabilities",
@@ -833,8 +794,6 @@
"data/PoolConfig",
"data/Query",
"data/QueryFilter",
- "data/Reaction",
- "data/RecordSubscription",
"data/ReferenceResolution",
"data/ReferenceResolutionError",
"data/ReplicationConfig",
@@ -860,7 +819,6 @@
"data/SpecialOperator",
"data/StateMachineValidation",
"data/StringOperator",
- "data/SubscriptionEventType",
"data/TenancyConfig",
"data/TimeUpdateInterval",
"data/TransformType",
diff --git a/packages/spec/src/api/feed-api.test.ts b/packages/spec/src/api/feed-api.test.ts
deleted file mode 100644
index c174a0eb32..0000000000
--- a/packages/spec/src/api/feed-api.test.ts
+++ /dev/null
@@ -1,850 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- FeedPathParamsSchema,
- FeedItemPathParamsSchema,
- GetFeedRequestSchema,
- GetFeedResponseSchema,
- CreateFeedItemRequestSchema,
- CreateFeedItemResponseSchema,
- UpdateFeedItemRequestSchema,
- UpdateFeedItemResponseSchema,
- DeleteFeedItemRequestSchema,
- DeleteFeedItemResponseSchema,
- AddReactionRequestSchema,
- AddReactionResponseSchema,
- RemoveReactionRequestSchema,
- RemoveReactionResponseSchema,
- SubscribeRequestSchema,
- SubscribeResponseSchema,
- FeedUnsubscribeRequestSchema,
- UnsubscribeResponseSchema,
- FeedApiErrorCode,
- FeedApiContracts,
- PinFeedItemRequestSchema,
- PinFeedItemResponseSchema,
- StarFeedItemRequestSchema,
- StarFeedItemResponseSchema,
- SearchFeedRequestSchema,
- SearchFeedResponseSchema,
- GetChangelogRequestSchema,
- ChangelogEntrySchema,
- GetChangelogResponseSchema,
-} from './feed-api.zod';
-
-// ==========================================
-// Path Parameters
-// ==========================================
-
-describe('FeedPathParamsSchema', () => {
- it('should accept valid path params', () => {
- const params = FeedPathParamsSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- });
- expect(params.object).toBe('account');
- expect(params.recordId).toBe('rec_123');
- });
-
- it('should reject missing object', () => {
- expect(() => FeedPathParamsSchema.parse({ recordId: 'rec_123' })).toThrow();
- });
-
- it('should reject missing recordId', () => {
- expect(() => FeedPathParamsSchema.parse({ object: 'account' })).toThrow();
- });
-});
-
-describe('FeedItemPathParamsSchema', () => {
- it('should accept valid item path params', () => {
- const params = FeedItemPathParamsSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- feedId: 'feed_001',
- });
- expect(params.feedId).toBe('feed_001');
- });
-
- it('should reject missing feedId', () => {
- expect(() =>
- FeedItemPathParamsSchema.parse({ object: 'account', recordId: 'rec_123' })
- ).toThrow();
- });
-});
-
-// ==========================================
-// Feed List (GET)
-// ==========================================
-
-describe('GetFeedRequestSchema', () => {
- it('should accept request with defaults', () => {
- const req = GetFeedRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- });
- expect(req.type).toBe('all');
- expect(req.limit).toBe(20);
- expect(req.cursor).toBeUndefined();
- });
-
- it('should accept request with all fields', () => {
- const req = GetFeedRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- type: 'comments_only',
- limit: 50,
- cursor: 'cursor_abc',
- });
- expect(req.type).toBe('comments_only');
- expect(req.limit).toBe(50);
- expect(req.cursor).toBe('cursor_abc');
- });
-
- it('should reject limit exceeding max', () => {
- expect(() =>
- GetFeedRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- limit: 200,
- })
- ).toThrow();
- });
-
- it('should reject limit below min', () => {
- expect(() =>
- GetFeedRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- limit: 0,
- })
- ).toThrow();
- });
-
- it('should reject invalid filter type', () => {
- expect(() =>
- GetFeedRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- type: 'invalid_filter',
- })
- ).toThrow();
- });
-});
-
-describe('GetFeedResponseSchema', () => {
- it('should accept valid response with items', () => {
- const resp = GetFeedResponseSchema.parse({
- success: true,
- data: {
- items: [
- {
- id: 'feed_001',
- type: 'comment',
- object: 'account',
- recordId: 'rec_123',
- actor: { type: 'user', id: 'user_456', name: 'John Smith' },
- body: 'Great progress!',
- createdAt: '2026-01-15T10:30:00Z',
- },
- ],
- hasMore: false,
- },
- });
- expect(resp.data.items).toHaveLength(1);
- expect(resp.data.items[0].type).toBe('comment');
- expect(resp.data.hasMore).toBe(false);
- });
-
- it('should accept response with pagination', () => {
- const resp = GetFeedResponseSchema.parse({
- success: true,
- data: {
- items: [],
- total: 42,
- nextCursor: 'cursor_next',
- hasMore: true,
- },
- });
- expect(resp.data.total).toBe(42);
- expect(resp.data.nextCursor).toBe('cursor_next');
- expect(resp.data.hasMore).toBe(true);
- });
-
- it('should reject missing hasMore', () => {
- expect(() =>
- GetFeedResponseSchema.parse({
- success: true,
- data: { items: [] },
- })
- ).toThrow();
- });
-});
-
-// ==========================================
-// Feed Create (POST)
-// ==========================================
-
-describe('CreateFeedItemRequestSchema', () => {
- it('should accept minimal comment request', () => {
- const req = CreateFeedItemRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- type: 'comment',
- body: 'Hello!',
- });
- expect(req.type).toBe('comment');
- expect(req.body).toBe('Hello!');
- expect(req.visibility).toBe('public');
- });
-
- it('should accept comment with mentions and visibility', () => {
- const req = CreateFeedItemRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- type: 'comment',
- body: 'Hey @jane',
- mentions: [
- { type: 'user', id: 'user_789', name: 'Jane Doe', offset: 4, length: 5 },
- ],
- visibility: 'internal',
- });
- expect(req.mentions).toHaveLength(1);
- expect(req.visibility).toBe('internal');
- });
-
- it('should accept threaded reply', () => {
- const req = CreateFeedItemRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- type: 'comment',
- body: 'Reply text',
- parentId: 'feed_001',
- });
- expect(req.parentId).toBe('feed_001');
- });
-
- it('should reject missing type', () => {
- expect(() =>
- CreateFeedItemRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- body: 'Hello',
- })
- ).toThrow();
- });
-
- it('should reject invalid feed item type', () => {
- expect(() =>
- CreateFeedItemRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- type: 'unknown_type',
- })
- ).toThrow();
- });
-});
-
-describe('CreateFeedItemResponseSchema', () => {
- it('should accept valid creation response', () => {
- const resp = CreateFeedItemResponseSchema.parse({
- success: true,
- data: {
- id: 'feed_002',
- type: 'comment',
- object: 'account',
- recordId: 'rec_123',
- actor: { type: 'user', id: 'user_456', name: 'John' },
- body: 'New comment',
- createdAt: '2026-01-15T11:00:00Z',
- },
- });
- expect(resp.data.id).toBe('feed_002');
- });
-});
-
-// ==========================================
-// Feed Update (PUT)
-// ==========================================
-
-describe('UpdateFeedItemRequestSchema', () => {
- it('should accept body update', () => {
- const req = UpdateFeedItemRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- feedId: 'feed_001',
- body: 'Updated comment',
- });
- expect(req.body).toBe('Updated comment');
- expect(req.feedId).toBe('feed_001');
- });
-
- it('should accept visibility update', () => {
- const req = UpdateFeedItemRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- feedId: 'feed_001',
- visibility: 'private',
- });
- expect(req.visibility).toBe('private');
- });
-
- it('should reject missing feedId', () => {
- expect(() =>
- UpdateFeedItemRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- body: 'Updated',
- })
- ).toThrow();
- });
-});
-
-describe('UpdateFeedItemResponseSchema', () => {
- it('should accept valid update response', () => {
- const resp = UpdateFeedItemResponseSchema.parse({
- success: true,
- data: {
- id: 'feed_001',
- type: 'comment',
- object: 'account',
- recordId: 'rec_123',
- actor: { type: 'user', id: 'user_456' },
- body: 'Updated comment',
- createdAt: '2026-01-15T10:30:00Z',
- editedAt: '2026-01-15T11:00:00Z',
- isEdited: true,
- },
- });
- expect(resp.data.isEdited).toBe(true);
- expect(resp.data.editedAt).toBeDefined();
- });
-});
-
-// ==========================================
-// Feed Delete (DELETE)
-// ==========================================
-
-describe('DeleteFeedItemRequestSchema', () => {
- it('should accept valid delete params', () => {
- const req = DeleteFeedItemRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- feedId: 'feed_001',
- });
- expect(req.feedId).toBe('feed_001');
- });
-});
-
-describe('DeleteFeedItemResponseSchema', () => {
- it('should accept valid delete response', () => {
- const resp = DeleteFeedItemResponseSchema.parse({
- success: true,
- data: { feedId: 'feed_001' },
- });
- expect(resp.data.feedId).toBe('feed_001');
- });
-
- it('should reject missing feedId in response data', () => {
- expect(() =>
- DeleteFeedItemResponseSchema.parse({
- success: true,
- data: {},
- })
- ).toThrow();
- });
-});
-
-// ==========================================
-// Reactions
-// ==========================================
-
-describe('AddReactionRequestSchema', () => {
- it('should accept valid reaction', () => {
- const req = AddReactionRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- feedId: 'feed_001',
- emoji: '👍',
- });
- expect(req.emoji).toBe('👍');
- });
-
- it('should accept shortcode emoji', () => {
- const req = AddReactionRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- feedId: 'feed_001',
- emoji: ':thumbsup:',
- });
- expect(req.emoji).toBe(':thumbsup:');
- });
-
- it('should reject missing emoji', () => {
- expect(() =>
- AddReactionRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- feedId: 'feed_001',
- })
- ).toThrow();
- });
-});
-
-describe('AddReactionResponseSchema', () => {
- it('should accept valid reaction response', () => {
- const resp = AddReactionResponseSchema.parse({
- success: true,
- data: {
- reactions: [
- { emoji: '👍', userIds: ['user_456'], count: 1 },
- ],
- },
- });
- expect(resp.data.reactions).toHaveLength(1);
- expect(resp.data.reactions[0].count).toBe(1);
- });
-});
-
-describe('RemoveReactionRequestSchema', () => {
- it('should accept valid removal', () => {
- const req = RemoveReactionRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- feedId: 'feed_001',
- emoji: '👍',
- });
- expect(req.emoji).toBe('👍');
- });
-});
-
-describe('RemoveReactionResponseSchema', () => {
- it('should accept valid removal response with empty reactions', () => {
- const resp = RemoveReactionResponseSchema.parse({
- success: true,
- data: { reactions: [] },
- });
- expect(resp.data.reactions).toHaveLength(0);
- });
-});
-
-// ==========================================
-// Subscription
-// ==========================================
-
-describe('SubscribeRequestSchema', () => {
- it('should accept request with defaults', () => {
- const req = SubscribeRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- });
- expect(req.events).toEqual(['all']);
- expect(req.channels).toEqual(['in_app']);
- });
-
- it('should accept request with specific events and channels', () => {
- const req = SubscribeRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- events: ['comment', 'field_change'],
- channels: ['in_app', 'email'],
- });
- expect(req.events).toEqual(['comment', 'field_change']);
- expect(req.channels).toEqual(['in_app', 'email']);
- });
-
- it('should reject invalid event type', () => {
- expect(() =>
- SubscribeRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- events: ['unknown_event'],
- })
- ).toThrow();
- });
-
- it('should reject invalid channel', () => {
- expect(() =>
- SubscribeRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- channels: ['sms'],
- })
- ).toThrow();
- });
-});
-
-describe('SubscribeResponseSchema', () => {
- it('should accept valid subscription response', () => {
- const resp = SubscribeResponseSchema.parse({
- success: true,
- data: {
- object: 'account',
- recordId: 'rec_123',
- userId: 'user_456',
- events: ['comment', 'field_change'],
- channels: ['in_app', 'email'],
- active: true,
- createdAt: '2026-01-15T10:00:00Z',
- },
- });
- expect(resp.data.userId).toBe('user_456');
- expect(resp.data.active).toBe(true);
- });
-});
-
-describe('FeedUnsubscribeRequestSchema', () => {
- it('should accept valid unsubscribe params', () => {
- const req = FeedUnsubscribeRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- });
- expect(req.object).toBe('account');
- expect(req.recordId).toBe('rec_123');
- });
-});
-
-describe('UnsubscribeResponseSchema', () => {
- it('should accept valid unsubscribe response', () => {
- const resp = UnsubscribeResponseSchema.parse({
- success: true,
- data: {
- object: 'account',
- recordId: 'rec_123',
- unsubscribed: true,
- },
- });
- expect(resp.data.unsubscribed).toBe(true);
- });
-
- it('should reject missing unsubscribed flag', () => {
- expect(() =>
- UnsubscribeResponseSchema.parse({
- success: true,
- data: {
- object: 'account',
- recordId: 'rec_123',
- },
- })
- ).toThrow();
- });
-});
-
-// ==========================================
-// Error Codes
-// ==========================================
-
-describe('FeedApiErrorCode', () => {
- it('should accept valid error codes', () => {
- expect(FeedApiErrorCode.parse('feed_item_not_found')).toBe('feed_item_not_found');
- expect(FeedApiErrorCode.parse('feed_permission_denied')).toBe('feed_permission_denied');
- expect(FeedApiErrorCode.parse('reaction_already_exists')).toBe('reaction_already_exists');
- });
-
- it('should reject invalid error code', () => {
- expect(() => FeedApiErrorCode.parse('unknown_error')).toThrow();
- });
-});
-
-// ==========================================
-// Contract Registry
-// ==========================================
-
-describe('FeedApiContracts', () => {
- it('should define all 14 endpoints', () => {
- expect(Object.keys(FeedApiContracts)).toHaveLength(14);
- });
-
- it('should have correct HTTP methods', () => {
- expect(FeedApiContracts.listFeed.method).toBe('GET');
- expect(FeedApiContracts.createFeedItem.method).toBe('POST');
- expect(FeedApiContracts.updateFeedItem.method).toBe('PUT');
- expect(FeedApiContracts.deleteFeedItem.method).toBe('DELETE');
- expect(FeedApiContracts.addReaction.method).toBe('POST');
- expect(FeedApiContracts.removeReaction.method).toBe('DELETE');
- expect(FeedApiContracts.pinFeedItem.method).toBe('POST');
- expect(FeedApiContracts.unpinFeedItem.method).toBe('DELETE');
- expect(FeedApiContracts.starFeedItem.method).toBe('POST');
- expect(FeedApiContracts.unstarFeedItem.method).toBe('DELETE');
- expect(FeedApiContracts.searchFeed.method).toBe('GET');
- expect(FeedApiContracts.getChangelog.method).toBe('GET');
- expect(FeedApiContracts.subscribe.method).toBe('POST');
- expect(FeedApiContracts.unsubscribe.method).toBe('DELETE');
- });
-
- it('should have valid paths', () => {
- expect(FeedApiContracts.listFeed.path).toContain('/feed');
- expect(FeedApiContracts.addReaction.path).toContain('/reactions');
- expect(FeedApiContracts.pinFeedItem.path).toContain('/pin');
- expect(FeedApiContracts.starFeedItem.path).toContain('/star');
- expect(FeedApiContracts.searchFeed.path).toContain('/feed/search');
- expect(FeedApiContracts.getChangelog.path).toContain('/changelog');
- expect(FeedApiContracts.subscribe.path).toContain('/subscribe');
- });
-});
-
-// ==========================================
-// Pin Feed Item
-// ==========================================
-
-describe('PinFeedItemRequestSchema', () => {
- it('should accept valid pin request', () => {
- const req = PinFeedItemRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- feedId: 'feed_001',
- });
- expect(req.feedId).toBe('feed_001');
- });
-
- it('should reject missing feedId', () => {
- expect(() =>
- PinFeedItemRequestSchema.parse({ object: 'account', recordId: 'rec_123' })
- ).toThrow();
- });
-});
-
-describe('PinFeedItemResponseSchema', () => {
- it('should accept valid pin response', () => {
- const resp = PinFeedItemResponseSchema.parse({
- success: true,
- data: {
- feedId: 'feed_001',
- pinned: true,
- pinnedAt: '2026-01-15T12:00:00Z',
- },
- });
- expect(resp.data.pinned).toBe(true);
- expect(resp.data.pinnedAt).toBeDefined();
- });
-
- it('should reject missing pinnedAt', () => {
- expect(() =>
- PinFeedItemResponseSchema.parse({
- success: true,
- data: { feedId: 'feed_001', pinned: true },
- })
- ).toThrow();
- });
-});
-
-// ==========================================
-// Star Feed Item
-// ==========================================
-
-describe('StarFeedItemRequestSchema', () => {
- it('should accept valid star request', () => {
- const req = StarFeedItemRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- feedId: 'feed_001',
- });
- expect(req.feedId).toBe('feed_001');
- });
-
- it('should reject missing object', () => {
- expect(() =>
- StarFeedItemRequestSchema.parse({ recordId: 'rec_123', feedId: 'feed_001' })
- ).toThrow();
- });
-});
-
-describe('StarFeedItemResponseSchema', () => {
- it('should accept valid star response', () => {
- const resp = StarFeedItemResponseSchema.parse({
- success: true,
- data: {
- feedId: 'feed_001',
- starred: true,
- starredAt: '2026-01-15T12:00:00Z',
- },
- });
- expect(resp.data.starred).toBe(true);
- expect(resp.data.starredAt).toBeDefined();
- });
-
- it('should reject missing starredAt', () => {
- expect(() =>
- StarFeedItemResponseSchema.parse({
- success: true,
- data: { feedId: 'feed_001', starred: true },
- })
- ).toThrow();
- });
-});
-
-// ==========================================
-// Search Feed
-// ==========================================
-
-describe('SearchFeedRequestSchema', () => {
- it('should accept a valid search request with defaults', () => {
- const req = SearchFeedRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- query: 'follow up',
- });
- expect(req.query).toBe('follow up');
- expect(req.limit).toBe(20);
- expect(req.type).toBeUndefined();
- });
-
- it('should accept search with all filters', () => {
- const req = SearchFeedRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- query: 'budget review',
- type: 'comments_only',
- actorId: 'user_456',
- dateFrom: '2026-01-01T00:00:00Z',
- dateTo: '2026-01-31T23:59:59Z',
- hasAttachments: true,
- pinnedOnly: false,
- starredOnly: true,
- limit: 50,
- cursor: 'cursor_abc',
- });
- expect(req.actorId).toBe('user_456');
- expect(req.hasAttachments).toBe(true);
- expect(req.starredOnly).toBe(true);
- });
-
- it('should reject empty query', () => {
- expect(() =>
- SearchFeedRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- query: '',
- })
- ).toThrow();
- });
-});
-
-describe('SearchFeedResponseSchema', () => {
- it('should accept valid search response', () => {
- const resp = SearchFeedResponseSchema.parse({
- success: true,
- data: {
- items: [
- {
- id: 'feed_001',
- type: 'comment',
- object: 'account',
- recordId: 'rec_123',
- actor: { type: 'user', id: 'user_456', name: 'John' },
- body: 'Follow up on budget',
- createdAt: '2026-01-15T10:30:00Z',
- },
- ],
- total: 1,
- hasMore: false,
- },
- });
- expect(resp.data.items).toHaveLength(1);
- expect(resp.data.hasMore).toBe(false);
- });
-});
-
-// ==========================================
-// Changelog
-// ==========================================
-
-describe('GetChangelogRequestSchema', () => {
- it('should accept request with defaults', () => {
- const req = GetChangelogRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- });
- expect(req.limit).toBe(50);
- expect(req.field).toBeUndefined();
- });
-
- it('should accept request with all filters', () => {
- const req = GetChangelogRequestSchema.parse({
- object: 'account',
- recordId: 'rec_123',
- field: 'status',
- actorId: 'user_456',
- dateFrom: '2026-01-01T00:00:00Z',
- dateTo: '2026-01-31T23:59:59Z',
- limit: 100,
- cursor: 'cursor_xyz',
- });
- expect(req.field).toBe('status');
- expect(req.limit).toBe(100);
- });
-});
-
-describe('ChangelogEntrySchema', () => {
- it('should accept a valid entry', () => {
- const entry = ChangelogEntrySchema.parse({
- id: 'cl_001',
- object: 'account',
- recordId: 'rec_123',
- actor: { type: 'user', id: 'user_456', name: 'Jane' },
- changes: [
- { field: 'status', oldValue: 'draft', newValue: 'active' },
- ],
- timestamp: '2026-01-15T10:30:00Z',
- source: 'UI',
- });
- expect(entry.changes).toHaveLength(1);
- expect(entry.actor.type).toBe('user');
- expect(entry.source).toBe('UI');
- });
-
- it('should reject empty changes array', () => {
- expect(() =>
- ChangelogEntrySchema.parse({
- id: 'cl_002',
- object: 'account',
- recordId: 'rec_123',
- actor: { type: 'system', id: 'sys' },
- changes: [],
- timestamp: '2026-01-15T10:30:00Z',
- })
- ).toThrow();
- });
-});
-
-describe('GetChangelogResponseSchema', () => {
- it('should accept a valid changelog response', () => {
- const resp = GetChangelogResponseSchema.parse({
- success: true,
- data: {
- entries: [
- {
- id: 'cl_001',
- object: 'account',
- recordId: 'rec_123',
- actor: { type: 'user', id: 'user_456' },
- changes: [{ field: 'name', oldValue: 'Old Corp', newValue: 'New Corp' }],
- timestamp: '2026-01-15T10:30:00Z',
- },
- ],
- total: 1,
- hasMore: false,
- },
- });
- expect(resp.data.entries).toHaveLength(1);
- expect(resp.data.hasMore).toBe(false);
- });
-});
-
-// ==========================================
-// New FeedApiErrorCode Values
-// ==========================================
-
-describe('FeedApiErrorCode (new values)', () => {
- it('should accept pin-related error codes', () => {
- expect(FeedApiErrorCode.parse('feed_already_pinned')).toBe('feed_already_pinned');
- expect(FeedApiErrorCode.parse('feed_not_pinned')).toBe('feed_not_pinned');
- });
-
- it('should accept star-related error codes', () => {
- expect(FeedApiErrorCode.parse('feed_already_starred')).toBe('feed_already_starred');
- expect(FeedApiErrorCode.parse('feed_not_starred')).toBe('feed_not_starred');
- });
-
- it('should accept search-related error codes', () => {
- expect(FeedApiErrorCode.parse('feed_search_query_too_short')).toBe('feed_search_query_too_short');
- });
-});
diff --git a/packages/spec/src/api/feed-api.zod.ts b/packages/spec/src/api/feed-api.zod.ts
deleted file mode 100644
index 2898cf4c4f..0000000000
--- a/packages/spec/src/api/feed-api.zod.ts
+++ /dev/null
@@ -1,583 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-import { z } from 'zod';
-import { BaseResponseSchema } from './contract.zod';
-import {
- FeedItemType,
- FeedItemSchema,
- FeedVisibility,
- MentionSchema,
- ReactionSchema,
- FieldChangeEntrySchema,
-} from '../data/feed.zod';
-import {
- SubscriptionEventType,
- NotificationChannel,
- RecordSubscriptionSchema,
-} from '../data/subscription.zod';
-
-/**
- * Feed / Chatter API Protocol
- *
- * Defines the HTTP interface for the unified activity timeline (Feed).
- * Covers Feed CRUD, Emoji Reactions, Pin/Star, Search, Changelog,
- * and Record Subscription endpoints.
- *
- * Base path: /api/data/{object}/{recordId}/feed
- *
- * @example Endpoints
- * GET /api/data/{object}/{recordId}/feed — List feed items
- * POST /api/data/{object}/{recordId}/feed — Create feed item
- * PUT /api/data/{object}/{recordId}/feed/{feedId} — Update feed item
- * DELETE /api/data/{object}/{recordId}/feed/{feedId} — Delete feed item
- * POST /api/data/{object}/{recordId}/feed/{feedId}/reactions — Add reaction
- * DELETE /api/data/{object}/{recordId}/feed/{feedId}/reactions/{emoji} — Remove reaction
- * POST /api/data/{object}/{recordId}/feed/{feedId}/pin — Pin feed item
- * DELETE /api/data/{object}/{recordId}/feed/{feedId}/pin — Unpin feed item
- * POST /api/data/{object}/{recordId}/feed/{feedId}/star — Star feed item
- * DELETE /api/data/{object}/{recordId}/feed/{feedId}/star — Unstar feed item
- * GET /api/data/{object}/{recordId}/feed/search — Search feed items
- * GET /api/data/{object}/{recordId}/changelog — Get field-level changelog
- * POST /api/data/{object}/{recordId}/subscribe — Subscribe
- * DELETE /api/data/{object}/{recordId}/subscribe — Unsubscribe
- */
-
-// ==========================================
-// 1. Path Parameters
-// ==========================================
-
-/**
- * Common path parameters shared across all feed endpoints.
- */
-import { lazySchema } from '../shared/lazy-schema';
-export const FeedPathParamsSchema = lazySchema(() => z.object({
- object: z.string().describe('Object name (e.g., "account")'),
- recordId: z.string().describe('Record ID'),
-}));
-export type FeedPathParams = z.infer;
-
-/**
- * Path parameters for single-feed-item operations (update, delete).
- */
-export const FeedItemPathParamsSchema = lazySchema(() => FeedPathParamsSchema.extend({
- feedId: z.string().describe('Feed item ID'),
-}));
-export type FeedItemPathParams = z.infer;
-
-// ==========================================
-// 2. Feed List (GET)
-// ==========================================
-
-/**
- * Feed filter type for the list query.
- * Maps to FeedFilterMode: all | comments_only | changes_only | tasks_only
- */
-export const FeedListFilterType = z.enum([
- 'all',
- 'comments_only',
- 'changes_only',
- 'tasks_only',
-]);
-
-/**
- * Query parameters for listing feed items.
- *
- * @example GET /api/data/account/rec_123/feed?type=all&limit=20&cursor=xxx
- */
-export const GetFeedRequestSchema = lazySchema(() => FeedPathParamsSchema.extend({
- type: FeedListFilterType.default('all')
- .describe('Filter by feed item category'),
- limit: z.number().int().min(1).max(100).default(20)
- .describe('Maximum number of items to return'),
- cursor: z.string().optional()
- .describe('Cursor for pagination (opaque string from previous response)'),
-}));
-export type GetFeedRequest = z.infer;
-
-/**
- * Response for the feed list endpoint.
- */
-export const GetFeedResponseSchema = lazySchema(() => BaseResponseSchema.extend({
- data: z.object({
- items: z.array(FeedItemSchema).describe('Feed items in reverse chronological order'),
- total: z.number().int().optional().describe('Total feed items matching filter'),
- nextCursor: z.string().optional().describe('Cursor for the next page'),
- hasMore: z.boolean().describe('Whether more items are available'),
- }),
-}));
-export type GetFeedResponse = z.infer;
-
-// ==========================================
-// 3. Feed Create (POST)
-// ==========================================
-
-/**
- * Request body for creating a new feed item (comment, note, task, etc.).
- *
- * @example POST /api/data/account/rec_123/feed
- * { type: 'comment', body: 'Great progress! @jane can you follow up?', mentions: [...] }
- */
-export const CreateFeedItemRequestSchema = lazySchema(() => FeedPathParamsSchema.extend({
- type: FeedItemType.describe('Type of feed item to create'),
- body: z.string().optional()
- .describe('Rich text body (Markdown supported)'),
- mentions: z.array(MentionSchema).optional()
- .describe('Mentioned users, teams, or records'),
- parentId: z.string().optional()
- .describe('Parent feed item ID for threaded replies'),
- visibility: FeedVisibility.default('public')
- .describe('Visibility: public, internal, or private'),
-}));
-export type CreateFeedItemRequest = z.infer;
-
-/**
- * Response after creating a feed item.
- */
-export const CreateFeedItemResponseSchema = lazySchema(() => BaseResponseSchema.extend({
- data: FeedItemSchema.describe('The created feed item'),
-}));
-export type CreateFeedItemResponse = z.infer;
-
-// ==========================================
-// 4. Feed Update (PUT)
-// ==========================================
-
-/**
- * Request body for updating an existing feed item (e.g., editing a comment).
- *
- * @example PUT /api/data/account/rec_123/feed/feed_001
- * { body: 'Updated comment text', mentions: [...] }
- */
-export const UpdateFeedItemRequestSchema = lazySchema(() => FeedItemPathParamsSchema.extend({
- body: z.string().optional()
- .describe('Updated rich text body'),
- mentions: z.array(MentionSchema).optional()
- .describe('Updated mentions'),
- visibility: FeedVisibility.optional()
- .describe('Updated visibility'),
-}));
-export type UpdateFeedItemRequest = z.infer;
-
-/**
- * Response after updating a feed item.
- */
-export const UpdateFeedItemResponseSchema = lazySchema(() => BaseResponseSchema.extend({
- data: FeedItemSchema.describe('The updated feed item'),
-}));
-export type UpdateFeedItemResponse = z.infer;
-
-// ==========================================
-// 5. Feed Delete (DELETE)
-// ==========================================
-
-/**
- * Request parameters for deleting a feed item.
- *
- * @example DELETE /api/data/account/rec_123/feed/feed_001
- */
-export const DeleteFeedItemRequestSchema = lazySchema(() => FeedItemPathParamsSchema);
-export type DeleteFeedItemRequest = z.infer;
-
-/**
- * Response after deleting a feed item.
- */
-export const DeleteFeedItemResponseSchema = lazySchema(() => BaseResponseSchema.extend({
- data: z.object({
- feedId: z.string().describe('ID of the deleted feed item'),
- }),
-}));
-export type DeleteFeedItemResponse = z.infer;
-
-// ==========================================
-// 6. Reactions (POST / DELETE)
-// ==========================================
-
-/**
- * Request for adding an emoji reaction to a feed item.
- *
- * @example POST /api/data/account/rec_123/feed/feed_001/reactions
- * { emoji: '👍' }
- */
-export const AddReactionRequestSchema = lazySchema(() => FeedItemPathParamsSchema.extend({
- emoji: z.string().describe('Emoji character or shortcode (e.g., "👍", ":thumbsup:")'),
-}));
-export type AddReactionRequest = z.infer;
-
-/**
- * Response after adding a reaction.
- */
-export const AddReactionResponseSchema = lazySchema(() => BaseResponseSchema.extend({
- data: z.object({
- reactions: z.array(ReactionSchema).describe('Updated reaction list for the feed item'),
- }),
-}));
-export type AddReactionResponse = z.infer;
-
-/**
- * Request for removing an emoji reaction from a feed item.
- *
- * @example DELETE /api/data/account/rec_123/feed/feed_001/reactions/👍
- */
-export const RemoveReactionRequestSchema = lazySchema(() => FeedItemPathParamsSchema.extend({
- emoji: z.string().describe('Emoji character or shortcode to remove'),
-}));
-export type RemoveReactionRequest = z.infer;
-
-/**
- * Response after removing a reaction.
- */
-export const RemoveReactionResponseSchema = lazySchema(() => BaseResponseSchema.extend({
- data: z.object({
- reactions: z.array(ReactionSchema).describe('Updated reaction list for the feed item'),
- }),
-}));
-export type RemoveReactionResponse = z.infer;
-
-// ==========================================
-// 7. Pin / Star
-// ==========================================
-
-/**
- * Request for pinning a feed item to the top of the timeline.
- *
- * @example POST /api/data/account/rec_123/feed/feed_001/pin
- */
-export const PinFeedItemRequestSchema = lazySchema(() => FeedItemPathParamsSchema);
-export type PinFeedItemRequest = z.infer;
-
-/**
- * Response after pinning a feed item.
- */
-export const PinFeedItemResponseSchema = lazySchema(() => BaseResponseSchema.extend({
- data: z.object({
- feedId: z.string().describe('ID of the pinned feed item'),
- pinned: z.boolean().describe('Whether the item is now pinned'),
- pinnedAt: z.string().datetime().describe('Timestamp when pinned'),
- }),
-}));
-export type PinFeedItemResponse = z.infer;
-
-/**
- * Request for unpinning a feed item.
- *
- * @example DELETE /api/data/account/rec_123/feed/feed_001/pin
- */
-export const UnpinFeedItemRequestSchema = lazySchema(() => FeedItemPathParamsSchema);
-export type UnpinFeedItemRequest = z.infer;
-
-/**
- * Response after unpinning a feed item.
- */
-export const UnpinFeedItemResponseSchema = lazySchema(() => BaseResponseSchema.extend({
- data: z.object({
- feedId: z.string().describe('ID of the unpinned feed item'),
- pinned: z.boolean().describe('Whether the item is now pinned (should be false)'),
- }),
-}));
-export type UnpinFeedItemResponse = z.infer;
-
-/**
- * Request for starring (bookmarking) a feed item.
- *
- * @example POST /api/data/account/rec_123/feed/feed_001/star
- */
-export const StarFeedItemRequestSchema = lazySchema(() => FeedItemPathParamsSchema);
-export type StarFeedItemRequest = z.infer;
-
-/**
- * Response after starring a feed item.
- */
-export const StarFeedItemResponseSchema = lazySchema(() => BaseResponseSchema.extend({
- data: z.object({
- feedId: z.string().describe('ID of the starred feed item'),
- starred: z.boolean().describe('Whether the item is now starred'),
- starredAt: z.string().datetime().describe('Timestamp when starred'),
- }),
-}));
-export type StarFeedItemResponse = z.infer;
-
-/**
- * Request for unstarring a feed item.
- *
- * @example DELETE /api/data/account/rec_123/feed/feed_001/star
- */
-export const UnstarFeedItemRequestSchema = lazySchema(() => FeedItemPathParamsSchema);
-export type UnstarFeedItemRequest = z.infer;
-
-/**
- * Response after unstarring a feed item.
- */
-export const UnstarFeedItemResponseSchema = lazySchema(() => BaseResponseSchema.extend({
- data: z.object({
- feedId: z.string().describe('ID of the unstarred feed item'),
- starred: z.boolean().describe('Whether the item is now starred (should be false)'),
- }),
-}));
-export type UnstarFeedItemResponse = z.infer;
-
-// ==========================================
-// 8. Activity Feed Search & Filter
-// ==========================================
-
-/**
- * Request for searching feed items with full-text query and advanced filters.
- *
- * @example GET /api/data/account/rec_123/feed/search?query=follow+up&actorId=user_456&dateFrom=2026-01-01T00:00:00Z
- */
-export const SearchFeedRequestSchema = lazySchema(() => FeedPathParamsSchema.extend({
- query: z.string().min(1).describe('Full-text search query against feed body content'),
- type: FeedListFilterType.optional()
- .describe('Filter by feed item category'),
- actorId: z.string().optional()
- .describe('Filter by actor user ID'),
- dateFrom: z.string().datetime().optional()
- .describe('Filter feed items created after this timestamp'),
- dateTo: z.string().datetime().optional()
- .describe('Filter feed items created before this timestamp'),
- hasAttachments: z.boolean().optional()
- .describe('Filter for items with file attachments'),
- pinnedOnly: z.boolean().optional()
- .describe('Return only pinned items'),
- starredOnly: z.boolean().optional()
- .describe('Return only starred items'),
- limit: z.number().int().min(1).max(100).default(20)
- .describe('Maximum number of items to return'),
- cursor: z.string().optional()
- .describe('Cursor for pagination'),
-}));
-export type SearchFeedRequest = z.infer;
-
-/**
- * Response for the feed search endpoint.
- */
-export const SearchFeedResponseSchema = lazySchema(() => BaseResponseSchema.extend({
- data: z.object({
- items: z.array(FeedItemSchema).describe('Matching feed items sorted by relevance'),
- total: z.number().int().optional().describe('Total matching items'),
- nextCursor: z.string().optional().describe('Cursor for the next page'),
- hasMore: z.boolean().describe('Whether more items are available'),
- }),
-}));
-export type SearchFeedResponse = z.infer;
-
-// ==========================================
-// 9. Changelog (Field-Level Audit Trail)
-// ==========================================
-
-/**
- * Request for retrieving the field-level changelog of a record.
- *
- * @example GET /api/data/account/rec_123/changelog?field=status&limit=50
- */
-export const GetChangelogRequestSchema = lazySchema(() => FeedPathParamsSchema.extend({
- field: z.string().optional()
- .describe('Filter changelog to a specific field name'),
- actorId: z.string().optional()
- .describe('Filter changelog by actor user ID'),
- dateFrom: z.string().datetime().optional()
- .describe('Filter changes after this timestamp'),
- dateTo: z.string().datetime().optional()
- .describe('Filter changes before this timestamp'),
- limit: z.number().int().min(1).max(200).default(50)
- .describe('Maximum number of changelog entries to return'),
- cursor: z.string().optional()
- .describe('Cursor for pagination'),
-}));
-export type GetChangelogRequest = z.infer;
-
-/**
- * A single changelog entry representing one or more field changes at a point in time.
- */
-export const ChangelogEntrySchema = lazySchema(() => z.object({
- id: z.string().describe('Changelog entry ID'),
- object: z.string().describe('Object name'),
- recordId: z.string().describe('Record ID'),
- actor: z.object({
- type: z.enum(['user', 'system', 'service', 'automation']).describe('Actor type'),
- id: z.string().describe('Actor ID'),
- name: z.string().optional().describe('Actor display name'),
- }).describe('Who made the change'),
- changes: z.array(FieldChangeEntrySchema).min(1).describe('Field-level changes'),
- timestamp: z.string().datetime().describe('When the change occurred'),
- source: z.string().optional().describe('Change source (e.g., "API", "UI", "automation")'),
-}));
-export type ChangelogEntry = z.infer;
-
-/**
- * Response for the changelog endpoint.
- */
-export const GetChangelogResponseSchema = lazySchema(() => BaseResponseSchema.extend({
- data: z.object({
- entries: z.array(ChangelogEntrySchema).describe('Changelog entries in reverse chronological order'),
- total: z.number().int().optional().describe('Total changelog entries matching filter'),
- nextCursor: z.string().optional().describe('Cursor for the next page'),
- hasMore: z.boolean().describe('Whether more entries are available'),
- }),
-}));
-export type GetChangelogResponse = z.infer;
-
-// ==========================================
-// 10. Record Subscription (POST / DELETE)
-// ==========================================
-
-/**
- * Request for subscribing to record notifications.
- *
- * @example POST /api/data/account/rec_123/subscribe
- * { events: ['comment', 'field_change'], channels: ['in_app', 'email'] }
- */
-export const SubscribeRequestSchema = lazySchema(() => FeedPathParamsSchema.extend({
- events: z.array(SubscriptionEventType).default(['all'])
- .describe('Event types to subscribe to'),
- channels: z.array(NotificationChannel).default(['in_app'])
- .describe('Notification delivery channels'),
-}));
-export type SubscribeRequest = z.infer;
-
-/**
- * Response after subscribing.
- */
-export const SubscribeResponseSchema = lazySchema(() => BaseResponseSchema.extend({
- data: RecordSubscriptionSchema.describe('The created or updated subscription'),
-}));
-export type SubscribeResponse = z.infer;
-
-/**
- * Request for unsubscribing from record notifications.
- *
- * @example DELETE /api/data/account/rec_123/subscribe
- */
-export const FeedUnsubscribeRequestSchema = lazySchema(() => FeedPathParamsSchema);
-export type FeedUnsubscribeRequest = z.infer;
-
-/**
- * Response after unsubscribing.
- */
-export const UnsubscribeResponseSchema = lazySchema(() => BaseResponseSchema.extend({
- data: z.object({
- object: z.string().describe('Object name'),
- recordId: z.string().describe('Record ID'),
- unsubscribed: z.boolean().describe('Whether the user was unsubscribed'),
- }),
-}));
-export type UnsubscribeResponse = z.infer;
-
-// ==========================================
-// 11. Feed API Error Codes
-// ==========================================
-
-/**
- * Error codes specific to Feed/Chatter operations.
- */
-export const FeedApiErrorCode = z.enum([
- 'feed_item_not_found',
- 'feed_permission_denied',
- 'feed_item_not_editable',
- 'feed_invalid_parent',
- 'reaction_already_exists',
- 'reaction_not_found',
- 'subscription_already_exists',
- 'subscription_not_found',
- 'invalid_feed_type',
- 'feed_already_pinned',
- 'feed_not_pinned',
- 'feed_already_starred',
- 'feed_not_starred',
- 'feed_search_query_too_short',
-]);
-export type FeedApiErrorCode = z.infer;
-
-// ==========================================
-// 12. Feed API Contract Registry
-// ==========================================
-
-/**
- * Standard Feed API contracts map.
- * Used for generating SDKs, documentation, and route registration.
- */
-export const FeedApiContracts = {
- listFeed: {
- method: 'GET' as const,
- path: '/api/data/:object/:recordId/feed',
- input: GetFeedRequestSchema,
- output: GetFeedResponseSchema,
- },
- createFeedItem: {
- method: 'POST' as const,
- path: '/api/data/:object/:recordId/feed',
- input: CreateFeedItemRequestSchema,
- output: CreateFeedItemResponseSchema,
- },
- updateFeedItem: {
- method: 'PUT' as const,
- path: '/api/data/:object/:recordId/feed/:feedId',
- input: UpdateFeedItemRequestSchema,
- output: UpdateFeedItemResponseSchema,
- },
- deleteFeedItem: {
- method: 'DELETE' as const,
- path: '/api/data/:object/:recordId/feed/:feedId',
- input: DeleteFeedItemRequestSchema,
- output: DeleteFeedItemResponseSchema,
- },
- addReaction: {
- method: 'POST' as const,
- path: '/api/data/:object/:recordId/feed/:feedId/reactions',
- input: AddReactionRequestSchema,
- output: AddReactionResponseSchema,
- },
- removeReaction: {
- method: 'DELETE' as const,
- path: '/api/data/:object/:recordId/feed/:feedId/reactions/:emoji',
- input: RemoveReactionRequestSchema,
- output: RemoveReactionResponseSchema,
- },
- pinFeedItem: {
- method: 'POST' as const,
- path: '/api/data/:object/:recordId/feed/:feedId/pin',
- input: PinFeedItemRequestSchema,
- output: PinFeedItemResponseSchema,
- },
- unpinFeedItem: {
- method: 'DELETE' as const,
- path: '/api/data/:object/:recordId/feed/:feedId/pin',
- input: UnpinFeedItemRequestSchema,
- output: UnpinFeedItemResponseSchema,
- },
- starFeedItem: {
- method: 'POST' as const,
- path: '/api/data/:object/:recordId/feed/:feedId/star',
- input: StarFeedItemRequestSchema,
- output: StarFeedItemResponseSchema,
- },
- unstarFeedItem: {
- method: 'DELETE' as const,
- path: '/api/data/:object/:recordId/feed/:feedId/star',
- input: UnstarFeedItemRequestSchema,
- output: UnstarFeedItemResponseSchema,
- },
- searchFeed: {
- method: 'GET' as const,
- path: '/api/data/:object/:recordId/feed/search',
- input: SearchFeedRequestSchema,
- output: SearchFeedResponseSchema,
- },
- getChangelog: {
- method: 'GET' as const,
- path: '/api/data/:object/:recordId/changelog',
- input: GetChangelogRequestSchema,
- output: GetChangelogResponseSchema,
- },
- subscribe: {
- method: 'POST' as const,
- path: '/api/data/:object/:recordId/subscribe',
- input: SubscribeRequestSchema,
- output: SubscribeResponseSchema,
- },
- unsubscribe: {
- method: 'DELETE' as const,
- path: '/api/data/:object/:recordId/subscribe',
- input: FeedUnsubscribeRequestSchema,
- output: UnsubscribeResponseSchema,
- },
-};
diff --git a/packages/spec/src/api/index.ts b/packages/spec/src/api/index.ts
index 5f1b905031..bbe6068f73 100644
--- a/packages/spec/src/api/index.ts
+++ b/packages/spec/src/api/index.ts
@@ -43,7 +43,6 @@ export * from './metadata.zod';
export * from './dispatcher.zod';
export * from './plugin-rest-api.zod';
export * from './query-adapter.zod';
-export * from './feed-api.zod';
export * from './export.zod';
export * from './automation-api.zod';
export * from './package-api.zod';
diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts
index d180f0f222..d814f43d8c 100644
--- a/packages/spec/src/api/protocol.zod.ts
+++ b/packages/spec/src/api/protocol.zod.ts
@@ -17,36 +17,6 @@ import { ObjectPermissionSchema, FieldPermissionSchema } from '../security/permi
import { StateMachineSchema } from '../automation/state-machine.zod';
import { ActionDescriptorSchema } from '../automation/node-executor.zod';
import { TranslationDataSchema } from '../system/translation.zod';
-import type {
- GetFeedRequest,
- GetFeedResponse,
- CreateFeedItemRequest,
- CreateFeedItemResponse,
- UpdateFeedItemRequest,
- UpdateFeedItemResponse,
- DeleteFeedItemRequest,
- DeleteFeedItemResponse,
- AddReactionRequest,
- AddReactionResponse,
- RemoveReactionRequest,
- RemoveReactionResponse,
- PinFeedItemRequest,
- PinFeedItemResponse,
- UnpinFeedItemRequest,
- UnpinFeedItemResponse,
- StarFeedItemRequest,
- StarFeedItemResponse,
- UnstarFeedItemRequest,
- UnstarFeedItemResponse,
- SearchFeedRequest,
- SearchFeedResponse,
- GetChangelogRequest,
- GetChangelogResponse,
- SubscribeRequest,
- SubscribeResponse,
- FeedUnsubscribeRequest,
- UnsubscribeResponse,
-} from './feed-api.zod';
import {
ListPackagesRequestSchema,
ListPackagesResponseSchema,
@@ -1152,36 +1122,6 @@ export const ObjectStackProtocolSchema = lazySchema(() => z.object({
.describe('Get translations for a locale'),
getFieldLabels: z.function()
.describe('Get translated field labels for an object'),
-
- // Feed Operations
- listFeed: z.function()
- .describe('List feed items for a record'),
- createFeedItem: z.function()
- .describe('Create a new feed item'),
- updateFeedItem: z.function()
- .describe('Update an existing feed item'),
- deleteFeedItem: z.function()
- .describe('Delete a feed item'),
- addReaction: z.function()
- .describe('Add an emoji reaction to a feed item'),
- removeReaction: z.function()
- .describe('Remove an emoji reaction from a feed item'),
- pinFeedItem: z.function()
- .describe('Pin a feed item'),
- unpinFeedItem: z.function()
- .describe('Unpin a feed item'),
- starFeedItem: z.function()
- .describe('Star a feed item'),
- unstarFeedItem: z.function()
- .describe('Unstar a feed item'),
- searchFeed: z.function()
- .describe('Search feed items'),
- getChangelog: z.function()
- .describe('Get field-level changelog for a record'),
- feedSubscribe: z.function()
- .describe('Subscribe to record notifications'),
- feedUnsubscribe: z.function()
- .describe('Unsubscribe from record notifications'),
}));
/**
@@ -1313,38 +1253,6 @@ export type GetTranslationsResponse = z.infer;
export type GetFieldLabelsResponse = z.infer;
-// Feed Types (re-exported from feed-api.zod.ts for convenience)
-export type {
- GetFeedRequest,
- GetFeedResponse,
- CreateFeedItemRequest,
- CreateFeedItemResponse,
- UpdateFeedItemRequest,
- UpdateFeedItemResponse,
- DeleteFeedItemRequest,
- DeleteFeedItemResponse,
- AddReactionRequest,
- AddReactionResponse,
- RemoveReactionRequest,
- RemoveReactionResponse,
- PinFeedItemRequest,
- PinFeedItemResponse,
- UnpinFeedItemRequest,
- UnpinFeedItemResponse,
- StarFeedItemRequest,
- StarFeedItemResponse,
- UnstarFeedItemRequest,
- UnstarFeedItemResponse,
- SearchFeedRequest,
- SearchFeedResponse,
- GetChangelogRequest,
- GetChangelogResponse,
- SubscribeRequest,
- SubscribeResponse,
- FeedUnsubscribeRequest,
- UnsubscribeResponse,
-} from './feed-api.zod';
-
// Package Management Types (re-exported from kernel for convenience)
export type {
ListPackagesRequest,
@@ -1500,24 +1408,6 @@ export interface I18nProtocol {
getFieldLabels?(request: GetFieldLabelsRequest): Promise;
}
-/** Feed / social (optional). */
-export interface FeedProtocol {
- listFeed?(request: GetFeedRequest): Promise;
- createFeedItem?(request: CreateFeedItemRequest): Promise;
- updateFeedItem?(request: UpdateFeedItemRequest): Promise;
- deleteFeedItem?(request: DeleteFeedItemRequest): Promise;
- addReaction?(request: AddReactionRequest): Promise;
- removeReaction?(request: RemoveReactionRequest): Promise;
- pinFeedItem?(request: PinFeedItemRequest): Promise;
- unpinFeedItem?(request: UnpinFeedItemRequest): Promise;
- starFeedItem?(request: StarFeedItemRequest): Promise;
- unstarFeedItem?(request: UnstarFeedItemRequest): Promise;
- searchFeed?(request: SearchFeedRequest): Promise;
- getChangelog?(request: GetChangelogRequest): Promise;
- feedSubscribe?(request: SubscribeRequest): Promise;
- feedUnsubscribe?(request: FeedUnsubscribeRequest): Promise;
-}
-
/**
* ObjectStackProtocol — composition of the per-domain contracts above
* (ADR-0076 D9). Shape-identical to the historical flat interface, so every
@@ -1538,5 +1428,4 @@ export interface ObjectStackProtocol extends
RealtimeProtocol,
NotificationProtocol,
AiProtocol,
- I18nProtocol,
- FeedProtocol {}
+ I18nProtocol {}
diff --git a/packages/spec/src/contracts/feed-service.test.ts b/packages/spec/src/contracts/feed-service.test.ts
deleted file mode 100644
index 68a203eefa..0000000000
--- a/packages/spec/src/contracts/feed-service.test.ts
+++ /dev/null
@@ -1,194 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-import { describe, it, expect } from 'vitest';
-import type { IFeedService } from './feed-service';
-
-describe('Feed Service Contract', () => {
- it('should allow a minimal IFeedService implementation with all required methods', () => {
- const service: IFeedService = {
- listFeed: async () => ({ items: [], hasMore: false }),
- createFeedItem: async () => ({
- id: 'feed_1',
- type: 'comment',
- object: 'account',
- recordId: 'rec_1',
- actor: { type: 'user', id: 'user_1' },
- visibility: 'public',
- replyCount: 0,
- isEdited: false,
- createdAt: new Date().toISOString(),
- }),
- updateFeedItem: async () => ({
- id: 'feed_1',
- type: 'comment',
- object: 'account',
- recordId: 'rec_1',
- actor: { type: 'user', id: 'user_1' },
- visibility: 'public',
- replyCount: 0,
- isEdited: true,
- createdAt: new Date().toISOString(),
- }),
- deleteFeedItem: async () => {},
- getFeedItem: async () => null,
- addReaction: async () => [],
- removeReaction: async () => [],
- subscribe: async () => ({
- object: 'account',
- recordId: 'rec_1',
- userId: 'user_1',
- events: ['all'],
- channels: ['in_app'],
- active: true,
- createdAt: new Date().toISOString(),
- }),
- unsubscribe: async () => true,
- getSubscription: async () => null,
- };
-
- expect(typeof service.listFeed).toBe('function');
- expect(typeof service.createFeedItem).toBe('function');
- expect(typeof service.updateFeedItem).toBe('function');
- expect(typeof service.deleteFeedItem).toBe('function');
- expect(typeof service.getFeedItem).toBe('function');
- expect(typeof service.addReaction).toBe('function');
- expect(typeof service.removeReaction).toBe('function');
- expect(typeof service.subscribe).toBe('function');
- expect(typeof service.unsubscribe).toBe('function');
- expect(typeof service.getSubscription).toBe('function');
- });
-
- it('should create and retrieve a feed item', async () => {
- const items = new Map();
- let counter = 0;
-
- const service: IFeedService = {
- listFeed: async () => ({ items: Array.from(items.values()), hasMore: false }),
- createFeedItem: async (input) => {
- const id = `feed_${++counter}`;
- const item = {
- id,
- type: input.type as any,
- object: input.object,
- recordId: input.recordId,
- actor: input.actor,
- body: input.body,
- visibility: input.visibility ?? 'public',
- replyCount: 0,
- isEdited: false,
- createdAt: new Date().toISOString(),
- };
- items.set(id, item);
- return item;
- },
- updateFeedItem: async () => ({} as any),
- deleteFeedItem: async () => {},
- getFeedItem: async (feedId) => items.get(feedId) ?? null,
- addReaction: async () => [],
- removeReaction: async () => [],
- subscribe: async () => ({} as any),
- unsubscribe: async () => true,
- getSubscription: async () => null,
- };
-
- const item = await service.createFeedItem({
- object: 'account',
- recordId: 'rec_123',
- type: 'comment',
- actor: { type: 'user', id: 'user_1', name: 'Alice' },
- body: 'Hello world',
- });
-
- expect(item.id).toBeDefined();
- expect(item.body).toBe('Hello world');
-
- const fetched = await service.getFeedItem(item.id);
- expect(fetched).toEqual(item);
- });
-
- it('should list feed items', async () => {
- const service: IFeedService = {
- listFeed: async (options) => ({
- items: [
- {
- id: 'feed_1',
- type: 'comment',
- object: options.object,
- recordId: options.recordId,
- actor: { type: 'user', id: 'user_1' },
- visibility: 'public',
- replyCount: 0,
- isEdited: false,
- createdAt: new Date().toISOString(),
- },
- ],
- total: 1,
- hasMore: false,
- }),
- createFeedItem: async () => ({} as any),
- updateFeedItem: async () => ({} as any),
- deleteFeedItem: async () => {},
- getFeedItem: async () => null,
- addReaction: async () => [],
- removeReaction: async () => [],
- subscribe: async () => ({} as any),
- unsubscribe: async () => true,
- getSubscription: async () => null,
- };
-
- const result = await service.listFeed({ object: 'account', recordId: 'rec_123' });
- expect(result.items).toHaveLength(1);
- expect(result.hasMore).toBe(false);
- });
-
- it('should handle subscribe and unsubscribe', async () => {
- const subs = new Map();
-
- const service: IFeedService = {
- listFeed: async () => ({ items: [], hasMore: false }),
- createFeedItem: async () => ({} as any),
- updateFeedItem: async () => ({} as any),
- deleteFeedItem: async () => {},
- getFeedItem: async () => null,
- addReaction: async () => [],
- removeReaction: async () => [],
- subscribe: async (input) => {
- const sub = {
- object: input.object,
- recordId: input.recordId,
- userId: input.userId,
- events: input.events ?? ['all'],
- channels: input.channels ?? ['in_app'],
- active: true,
- createdAt: new Date().toISOString(),
- };
- subs.set(`${input.object}:${input.recordId}:${input.userId}`, sub);
- return sub;
- },
- unsubscribe: async (object, recordId, userId) => {
- return subs.delete(`${object}:${recordId}:${userId}`);
- },
- getSubscription: async (object, recordId, userId) => {
- return subs.get(`${object}:${recordId}:${userId}`) ?? null;
- },
- };
-
- const sub = await service.subscribe({
- object: 'account',
- recordId: 'rec_123',
- userId: 'user_1',
- events: ['comment'],
- });
- expect(sub.active).toBe(true);
- expect(sub.events).toEqual(['comment']);
-
- const fetched = await service.getSubscription('account', 'rec_123', 'user_1');
- expect(fetched).not.toBeNull();
-
- const result = await service.unsubscribe('account', 'rec_123', 'user_1');
- expect(result).toBe(true);
-
- const gone = await service.getSubscription('account', 'rec_123', 'user_1');
- expect(gone).toBeNull();
- });
-});
diff --git a/packages/spec/src/contracts/feed-service.ts b/packages/spec/src/contracts/feed-service.ts
deleted file mode 100644
index 0d1e84a182..0000000000
--- a/packages/spec/src/contracts/feed-service.ts
+++ /dev/null
@@ -1,222 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-/**
- * IFeedService - Feed/Chatter Service Contract
- *
- * Defines the interface for Feed/Chatter operations in ObjectStack.
- * Covers feed CRUD, emoji reactions, and record subscriptions.
- * Concrete implementations (in-memory, database-backed, etc.)
- * should implement this interface.
- *
- * Follows Dependency Inversion Principle - plugins depend on this interface,
- * not on concrete feed service implementations.
- *
- * Aligned with CoreServiceName 'feed' in core-services.zod.ts.
- */
-
-import type { FeedItem, Reaction } from '../data/feed.zod';
-import type { RecordSubscription } from '../data/subscription.zod';
-
-// ==========================================
-// Feed Item Types
-// ==========================================
-
-/**
- * Input for creating a new feed item.
- */
-export interface CreateFeedItemInput {
- /** Object name (e.g., "account") */
- object: string;
- /** Record ID */
- recordId: string;
- /** Feed item type */
- type: string;
- /** Actor information */
- actor: {
- type: 'user' | 'system' | 'service' | 'automation';
- id: string;
- name?: string;
- avatarUrl?: string;
- };
- /** Rich text body (Markdown) */
- body?: string;
- /** @mentions */
- mentions?: Array<{
- type: 'user' | 'team' | 'record';
- id: string;
- name: string;
- offset: number;
- length: number;
- }>;
- /** Field changes (for field_change type) */
- changes?: Array<{
- field: string;
- fieldLabel?: string;
- oldValue?: unknown;
- newValue?: unknown;
- oldDisplayValue?: string;
- newDisplayValue?: string;
- }>;
- /** Parent feed item ID for threaded replies */
- parentId?: string;
- /** Visibility level */
- visibility?: 'public' | 'internal' | 'private';
-}
-
-/**
- * Input for updating an existing feed item.
- */
-export interface UpdateFeedItemInput {
- /** Updated body text */
- body?: string;
- /** Updated mentions */
- mentions?: Array<{
- type: 'user' | 'team' | 'record';
- id: string;
- name: string;
- offset: number;
- length: number;
- }>;
- /** Updated visibility */
- visibility?: 'public' | 'internal' | 'private';
-}
-
-/**
- * Options for listing feed items.
- */
-export interface ListFeedOptions {
- /** Object name */
- object: string;
- /** Record ID */
- recordId: string;
- /** Filter mode */
- filter?: 'all' | 'comments_only' | 'changes_only' | 'tasks_only';
- /** Maximum items to return */
- limit?: number;
- /** Cursor for pagination */
- cursor?: string;
-}
-
-/**
- * Paginated feed list result.
- */
-export interface FeedListResult {
- /** Feed items in reverse chronological order */
- items: FeedItem[];
- /** Total feed items matching filter */
- total?: number;
- /** Cursor for next page */
- nextCursor?: string;
- /** Whether more items are available */
- hasMore: boolean;
-}
-
-// ==========================================
-// Subscription Types
-// ==========================================
-
-/**
- * Input for subscribing to record notifications.
- */
-export interface SubscribeInput {
- /** Object name */
- object: string;
- /** Record ID */
- recordId: string;
- /** Subscribing user ID */
- userId: string;
- /** Event types to subscribe to */
- events?: Array<'comment' | 'mention' | 'field_change' | 'task' | 'approval' | 'all'>;
- /** Notification channels */
- channels?: Array<'in_app' | 'email' | 'push' | 'slack'>;
-}
-
-// ==========================================
-// Service Interface
-// ==========================================
-
-export interface IFeedService {
- // ---- Feed CRUD ----
-
- /**
- * List feed items for a record.
- * @param options - Filter and pagination options
- * @returns Paginated list of feed items
- */
- listFeed(options: ListFeedOptions): Promise;
-
- /**
- * Create a new feed item.
- * @param input - Feed item data
- * @returns The created feed item
- */
- createFeedItem(input: CreateFeedItemInput): Promise;
-
- /**
- * Update an existing feed item (e.g., edit a comment).
- * @param feedId - Feed item ID
- * @param input - Updated fields
- * @returns The updated feed item
- */
- updateFeedItem(feedId: string, input: UpdateFeedItemInput): Promise;
-
- /**
- * Delete a feed item.
- * @param feedId - Feed item ID
- */
- deleteFeedItem(feedId: string): Promise;
-
- /**
- * Get a single feed item by ID.
- * @param feedId - Feed item ID
- * @returns The feed item, or null if not found
- */
- getFeedItem(feedId: string): Promise;
-
- // ---- Reactions ----
-
- /**
- * Add an emoji reaction to a feed item.
- * @param feedId - Feed item ID
- * @param emoji - Emoji character or shortcode
- * @param userId - User adding the reaction
- * @returns Updated reactions list
- */
- addReaction(feedId: string, emoji: string, userId: string): Promise;
-
- /**
- * Remove an emoji reaction from a feed item.
- * @param feedId - Feed item ID
- * @param emoji - Emoji character or shortcode
- * @param userId - User removing the reaction
- * @returns Updated reactions list
- */
- removeReaction(feedId: string, emoji: string, userId: string): Promise;
-
- // ---- Subscriptions ----
-
- /**
- * Subscribe to record-level notifications.
- * @param input - Subscription details
- * @returns The created or updated subscription
- */
- subscribe(input: SubscribeInput): Promise;
-
- /**
- * Unsubscribe from record notifications.
- * @param object - Object name
- * @param recordId - Record ID
- * @param userId - User ID
- * @returns Whether the user was unsubscribed
- */
- unsubscribe(object: string, recordId: string, userId: string): Promise;
-
- /**
- * Get a user's subscription for a record.
- * @param object - Object name
- * @param recordId - Record ID
- * @param userId - User ID
- * @returns The subscription, or null if not subscribed
- */
- getSubscription(object: string, recordId: string, userId: string): Promise;
-}
diff --git a/packages/spec/src/contracts/index.ts b/packages/spec/src/contracts/index.ts
index 8a1a4306c1..a1c5867f71 100644
--- a/packages/spec/src/contracts/index.ts
+++ b/packages/spec/src/contracts/index.ts
@@ -32,7 +32,6 @@ export * from './ai-service.js';
export * from './llm-adapter.js';
export * from './i18n-service.js';
export * from './workflow-service.js';
-export * from './feed-service.js';
export * from './export-service.js';
export * from './email-service.js';
export * from './sms-service.js';
diff --git a/packages/spec/src/data/feed.test.ts b/packages/spec/src/data/feed.test.ts
index 934ef97be8..3a59a53a9f 100644
--- a/packages/spec/src/data/feed.test.ts
+++ b/packages/spec/src/data/feed.test.ts
@@ -1,19 +1,5 @@
import { describe, it, expect } from 'vitest';
-import {
- FeedItemType,
- MentionSchema,
- FieldChangeEntrySchema,
- ReactionSchema,
- FeedActorSchema,
- FeedVisibility,
- FeedItemSchema,
- FeedFilterMode,
- type FeedItem,
- type Mention,
- type FieldChangeEntry,
- type Reaction,
- type FeedActor,
-} from './feed.zod';
+import { FeedItemType, FeedFilterMode } from './feed.zod';
describe('FeedItemType', () => {
it('should accept all valid feed item types', () => {
@@ -33,151 +19,6 @@ describe('FeedItemType', () => {
});
});
-describe('MentionSchema', () => {
- it('should accept a valid user mention', () => {
- const mention: Mention = {
- type: 'user',
- id: 'user_123',
- name: 'Jane Doe',
- offset: 17,
- length: 9,
- };
- const result = MentionSchema.parse(mention);
- expect(result.type).toBe('user');
- expect(result.id).toBe('user_123');
- expect(result.name).toBe('Jane Doe');
- expect(result.offset).toBe(17);
- expect(result.length).toBe(9);
- });
-
- it('should accept team and record mention types', () => {
- expect(() => MentionSchema.parse({ type: 'team', id: 'team_1', name: 'Engineering', offset: 0, length: 12 })).not.toThrow();
- expect(() => MentionSchema.parse({ type: 'record', id: 'rec_1', name: 'Acme Corp', offset: 5, length: 9 })).not.toThrow();
- });
-
- it('should reject invalid mention type', () => {
- expect(() => MentionSchema.parse({ type: 'group', id: '1', name: 'X', offset: 0, length: 1 })).toThrow();
- });
-
- it('should reject negative offset', () => {
- expect(() => MentionSchema.parse({ type: 'user', id: '1', name: 'X', offset: -1, length: 1 })).toThrow();
- });
-
- it('should reject zero length', () => {
- expect(() => MentionSchema.parse({ type: 'user', id: '1', name: 'X', offset: 0, length: 0 })).toThrow();
- });
-
- it('should reject missing required fields', () => {
- expect(() => MentionSchema.parse({})).toThrow();
- expect(() => MentionSchema.parse({ type: 'user' })).toThrow();
- });
-});
-
-describe('FieldChangeEntrySchema', () => {
- it('should accept minimal field change', () => {
- const result = FieldChangeEntrySchema.parse({ field: 'status' });
- expect(result.field).toBe('status');
- expect(result.oldValue).toBeUndefined();
- expect(result.newValue).toBeUndefined();
- });
-
- it('should accept full field change with display values', () => {
- const change: FieldChangeEntry = {
- field: 'region',
- fieldLabel: 'Region',
- oldValue: null,
- newValue: 'asia_pacific',
- oldDisplayValue: '',
- newDisplayValue: 'Asia-Pacific',
- };
- const result = FieldChangeEntrySchema.parse(change);
- expect(result.fieldLabel).toBe('Region');
- expect(result.newDisplayValue).toBe('Asia-Pacific');
- });
-
- it('should reject without field name', () => {
- expect(() => FieldChangeEntrySchema.parse({})).toThrow();
- });
-});
-
-describe('ReactionSchema', () => {
- it('should accept valid reaction', () => {
- const reaction: Reaction = {
- emoji: '👍',
- userIds: ['user_1', 'user_2'],
- count: 2,
- };
- const result = ReactionSchema.parse(reaction);
- expect(result.emoji).toBe('👍');
- expect(result.userIds).toHaveLength(2);
- expect(result.count).toBe(2);
- });
-
- it('should reject count less than 1', () => {
- expect(() => ReactionSchema.parse({ emoji: '👍', userIds: [], count: 0 })).toThrow();
- });
-
- it('should reject missing required fields', () => {
- expect(() => ReactionSchema.parse({})).toThrow();
- expect(() => ReactionSchema.parse({ emoji: '👍' })).toThrow();
- });
-});
-
-describe('FeedActorSchema', () => {
- it('should accept user actor', () => {
- const actor: FeedActor = {
- type: 'user',
- id: 'user_456',
- name: 'John Smith',
- };
- const result = FeedActorSchema.parse(actor);
- expect(result.type).toBe('user');
- expect(result.name).toBe('John Smith');
- });
-
- it('should accept system actor with source', () => {
- const result = FeedActorSchema.parse({
- type: 'system',
- id: 'sys_001',
- source: 'Omni',
- });
- expect(result.type).toBe('system');
- expect(result.source).toBe('Omni');
- });
-
- it('should accept all actor types', () => {
- const types = ['user', 'system', 'service', 'automation'];
- types.forEach(type => {
- expect(() => FeedActorSchema.parse({ type, id: 'test_1' })).not.toThrow();
- });
- });
-
- it('should accept actor with avatarUrl', () => {
- const result = FeedActorSchema.parse({
- type: 'user',
- id: 'user_1',
- avatarUrl: 'https://example.com/avatar.png',
- });
- expect(result.avatarUrl).toBe('https://example.com/avatar.png');
- });
-
- it('should reject invalid actor type', () => {
- expect(() => FeedActorSchema.parse({ type: 'bot', id: '1' })).toThrow();
- });
-});
-
-describe('FeedVisibility', () => {
- it('should accept valid visibility levels', () => {
- ['public', 'internal', 'private'].forEach(v => {
- expect(() => FeedVisibility.parse(v)).not.toThrow();
- });
- });
-
- it('should reject invalid visibility', () => {
- expect(() => FeedVisibility.parse('secret')).toThrow();
- });
-});
-
describe('FeedFilterMode', () => {
it('should accept valid filter modes', () => {
['all', 'comments_only', 'changes_only', 'tasks_only'].forEach(mode => {
@@ -189,149 +30,3 @@ describe('FeedFilterMode', () => {
expect(() => FeedFilterMode.parse('custom')).toThrow();
});
});
-
-describe('FeedItemSchema', () => {
- const minimalComment: FeedItem = {
- id: 'feed_001',
- type: 'comment',
- object: 'account',
- recordId: 'rec_123',
- actor: { type: 'user', id: 'user_456', name: 'John Smith' },
- body: 'Great progress on this deal!',
- createdAt: '2026-01-15T10:30:00Z',
- };
-
- it('should accept a minimal comment feed item', () => {
- const result = FeedItemSchema.parse(minimalComment);
- expect(result.id).toBe('feed_001');
- expect(result.type).toBe('comment');
- expect(result.object).toBe('account');
- expect(result.recordId).toBe('rec_123');
- expect(result.body).toBe('Great progress on this deal!');
- expect(result.replyCount).toBe(0);
- expect(result.visibility).toBe('public');
- expect(result.isEdited).toBe(false);
- });
-
- it('should accept a comment with mentions', () => {
- const result = FeedItemSchema.parse({
- ...minimalComment,
- mentions: [
- { type: 'user', id: 'user_789', name: 'Jane Doe', offset: 17, length: 9 },
- ],
- });
- expect(result.mentions).toHaveLength(1);
- expect(result.mentions![0].name).toBe('Jane Doe');
- });
-
- it('should accept a field_change feed item with changes', () => {
- const fieldChange: FeedItem = {
- id: 'feed_002',
- type: 'field_change',
- object: 'account',
- recordId: 'rec_123',
- actor: { type: 'user', id: 'user_456', name: 'John Smith' },
- changes: [
- { field: 'status', oldDisplayValue: 'New', newDisplayValue: 'Active' },
- { field: 'region', oldDisplayValue: '', newDisplayValue: 'Asia-Pacific' },
- ],
- createdAt: '2026-01-15T10:25:00Z',
- };
- const result = FeedItemSchema.parse(fieldChange);
- expect(result.type).toBe('field_change');
- expect(result.changes).toHaveLength(2);
- expect(result.changes![0].field).toBe('status');
- });
-
- it('should accept a threaded reply', () => {
- const result = FeedItemSchema.parse({
- ...minimalComment,
- id: 'feed_003',
- parentId: 'feed_001',
- });
- expect(result.parentId).toBe('feed_001');
- });
-
- it('should accept edited comment', () => {
- const result = FeedItemSchema.parse({
- ...minimalComment,
- isEdited: true,
- editedAt: '2026-01-15T11:00:00Z',
- });
- expect(result.isEdited).toBe(true);
- expect(result.editedAt).toBe('2026-01-15T11:00:00Z');
- });
-
- it('should accept reactions on a feed item', () => {
- const result = FeedItemSchema.parse({
- ...minimalComment,
- reactions: [
- { emoji: '👍', userIds: ['user_789'], count: 1 },
- { emoji: '❤️', userIds: ['user_101', 'user_102'], count: 2 },
- ],
- });
- expect(result.reactions).toHaveLength(2);
- });
-
- it('should accept internal visibility', () => {
- const result = FeedItemSchema.parse({
- ...minimalComment,
- visibility: 'internal',
- });
- expect(result.visibility).toBe('internal');
- });
-
- it('should accept system actor with source', () => {
- const result = FeedItemSchema.parse({
- ...minimalComment,
- actor: { type: 'system', id: 'sys_001', source: 'API' },
- });
- expect(result.actor.type).toBe('system');
- expect(result.actor.source).toBe('API');
- });
-
- it('should accept all feed item types', () => {
- const types = [
- 'comment', 'field_change', 'task', 'event', 'email', 'call',
- 'note', 'file', 'record_create', 'record_delete', 'approval',
- 'sharing', 'system',
- ];
- types.forEach(type => {
- expect(() => FeedItemSchema.parse({
- id: `feed_${type}`,
- type,
- object: 'account',
- recordId: 'rec_1',
- actor: { type: 'user', id: 'user_1' },
- createdAt: '2026-01-15T10:00:00Z',
- })).not.toThrow();
- });
- });
-
- it('should apply default values', () => {
- const result = FeedItemSchema.parse({
- id: 'feed_def',
- type: 'note',
- object: 'lead',
- recordId: 'rec_1',
- actor: { type: 'user', id: 'user_1' },
- createdAt: '2026-01-15T10:00:00Z',
- });
- expect(result.replyCount).toBe(0);
- expect(result.visibility).toBe('public');
- expect(result.isEdited).toBe(false);
- });
-
- it('should reject without required fields', () => {
- expect(() => FeedItemSchema.parse({})).toThrow();
- expect(() => FeedItemSchema.parse({ id: 'x' })).toThrow();
- expect(() => FeedItemSchema.parse({ id: 'x', type: 'comment' })).toThrow();
- });
-
- it('should reject invalid datetime format', () => {
- expect(() => FeedItemSchema.parse({
- ...minimalComment,
- createdAt: 'not-a-date',
- })).toThrow();
- });
-});
diff --git a/packages/spec/src/data/feed.zod.ts b/packages/spec/src/data/feed.zod.ts
index 211c8fcf10..cc3688f29e 100644
--- a/packages/spec/src/data/feed.zod.ts
+++ b/packages/spec/src/data/feed.zod.ts
@@ -2,12 +2,21 @@
import { z } from 'zod';
+/**
+ * Activity-timeline UI config enums.
+ *
+ * The `service-feed` backend was retired (ADR-0052 §5 / #1955); `sys_comment` /
+ * `sys_activity` are the canonical record-collaboration/timeline backend. Only these
+ * two enums remain here — they are pure UI configuration for the record activity
+ * component (`RecordActivityProps` in `../ui/component.zod.ts`), with no backend
+ * dependency. (A later `feed` → `activity` rename is tracked separately.)
+ */
+
/**
* Feed Item Type
* Unified activity types for the record timeline.
* Covers comments, field changes, tasks, events, and system activities.
*/
-import { lazySchema } from '../shared/lazy-schema';
export const FeedItemType = z.enum([
'comment',
'field_change',
@@ -25,142 +34,6 @@ export const FeedItemType = z.enum([
]);
export type FeedItemType = z.infer;
-/**
- * Mention Schema
- * Represents an @mention within comment body text.
- */
-export const MentionSchema = lazySchema(() => z.object({
- type: z.enum(['user', 'team', 'record']).describe('Mention target type'),
- id: z.string().describe('Target ID'),
- name: z.string().describe('Display name for rendering'),
- offset: z.number().int().min(0).describe('Character offset in body text'),
- length: z.number().int().min(1).describe('Length of mention token in body text'),
-}));
-export type Mention = z.infer;
-
-/**
- * Field Change Entry Schema
- * Represents a single field-level change within a field_change feed item.
- */
-export const FieldChangeEntrySchema = lazySchema(() => z.object({
- field: z.string().describe('Field machine name'),
- fieldLabel: z.string().optional().describe('Field display label'),
- oldValue: z.unknown().optional().describe('Previous value'),
- newValue: z.unknown().optional().describe('New value'),
- oldDisplayValue: z.string().optional().describe('Human-readable old value'),
- newDisplayValue: z.string().optional().describe('Human-readable new value'),
-}));
-export type FieldChangeEntry = z.infer;
-
-/**
- * Reaction Schema
- * Represents an emoji reaction on a feed item.
- */
-export const ReactionSchema = lazySchema(() => z.object({
- emoji: z.string().describe('Emoji character or shortcode (e.g., "👍", ":thumbsup:")'),
- userIds: z.array(z.string()).describe('Users who reacted'),
- count: z.number().int().min(1).describe('Total reaction count'),
-}));
-export type Reaction = z.infer;
-
-/**
- * Feed Actor Schema
- * Represents the actor who performed the action.
- */
-export const FeedActorSchema = lazySchema(() => z.object({
- type: z.enum(['user', 'system', 'service', 'automation']).describe('Actor type'),
- id: z.string().describe('Actor ID'),
- name: z.string().optional().describe('Actor display name'),
- avatarUrl: z.string().url().optional().describe('Actor avatar URL'),
- source: z.string().optional().describe('Source application (e.g., "Omni", "API", "Studio")'),
-}));
-export type FeedActor = z.infer;
-
-/**
- * Feed Item Visibility
- */
-export const FeedVisibility = z.enum(['public', 'internal', 'private']);
-export type FeedVisibility = z.infer;
-
-/**
- * Feed Item Schema
- * A single entry in the unified activity timeline.
- *
- * @example Comment
- * {
- * id: 'feed_001',
- * type: 'comment',
- * object: 'account',
- * recordId: 'rec_123',
- * body: 'Great progress! @jane.doe can you follow up?',
- * mentions: [{ type: 'user', id: 'user_123', name: 'Jane Doe', offset: 17, length: 9 }],
- * actor: { type: 'user', id: 'user_456', name: 'John Smith' },
- * createdAt: '2026-01-15T10:30:00Z',
- * }
- *
- * @example Field Change
- * {
- * id: 'feed_002',
- * type: 'field_change',
- * object: 'account',
- * recordId: 'rec_123',
- * changes: [
- * { field: 'status', oldDisplayValue: 'New', newDisplayValue: 'Active' },
- * { field: 'region', oldDisplayValue: '', newDisplayValue: 'Asia-Pacific' },
- * ],
- * actor: { type: 'user', id: 'user_456', name: 'John Smith' },
- * createdAt: '2026-01-15T10:25:00Z',
- * }
- */
-export const FeedItemSchema = lazySchema(() => z.object({
- /** Unique identifier */
- id: z.string().describe('Feed item ID'),
-
- /** Feed item type */
- type: FeedItemType.describe('Activity type'),
-
- /** Target record reference */
- object: z.string().describe('Object name (e.g., "account")'),
- recordId: z.string().describe('Record ID this feed item belongs to'),
-
- /** Actor (who performed the action) */
- actor: FeedActorSchema.describe('Who performed this action'),
-
- /** Content (for comments/notes) */
- body: z.string().optional().describe('Rich text body (Markdown supported)'),
-
- /** @Mentions */
- mentions: z.array(MentionSchema).optional().describe('Mentioned users/teams/records'),
-
- /** Field changes (for field_change type) */
- changes: z.array(FieldChangeEntrySchema).optional().describe('Field-level changes'),
-
- /** Reactions */
- reactions: z.array(ReactionSchema).optional().describe('Emoji reactions on this item'),
-
- /** Reply threading */
- parentId: z.string().optional().describe('Parent feed item ID for threaded replies'),
- replyCount: z.number().int().min(0).default(0).describe('Number of replies'),
-
- /** Pin / Star */
- pinned: z.boolean().default(false).describe('Whether the feed item is pinned to the top of the timeline'),
- pinnedAt: z.string().datetime().optional().describe('Timestamp when the item was pinned'),
- pinnedBy: z.string().optional().describe('User ID who pinned the item'),
- starred: z.boolean().default(false).describe('Whether the feed item is starred/bookmarked by the current user'),
- starredAt: z.string().datetime().optional().describe('Timestamp when the item was starred'),
-
- /** Visibility */
- visibility: FeedVisibility.default('public')
- .describe('Visibility: public (all users), internal (team only), private (author + mentioned)'),
-
- /** Timestamps */
- createdAt: z.string().datetime().describe('Creation timestamp'),
- updatedAt: z.string().datetime().optional().describe('Last update timestamp'),
- editedAt: z.string().datetime().optional().describe('When comment was last edited'),
- isEdited: z.boolean().default(false).describe('Whether comment has been edited'),
-}));
-export type FeedItem = z.infer;
-
/**
* Feed Filter Mode
* Controls which feed item types to display in the timeline.
diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts
index 77bd95d165..e2d49b5ea6 100644
--- a/packages/spec/src/data/index.ts
+++ b/packages/spec/src/data/index.ts
@@ -57,8 +57,6 @@ export * from './field-group-layout';
// create/edit/detail opens by default (full page vs drawer/modal overlay).
export * from './record-surface';
-// Feed & Activity Protocol
+// Feed & Activity Protocol — retains only the UI activity-timeline config enums
+// (FeedItemType / FeedFilterMode); the feed backend contracts were retired (ADR-0052 §5).
export * from './feed.zod';
-
-// Subscription Protocol
-export * from './subscription.zod';
diff --git a/packages/spec/src/data/subscription.test.ts b/packages/spec/src/data/subscription.test.ts
deleted file mode 100644
index 05f0dfc2e2..0000000000
--- a/packages/spec/src/data/subscription.test.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- SubscriptionEventType,
- NotificationChannel,
- RecordSubscriptionSchema,
- type RecordSubscription,
-} from './subscription.zod';
-
-describe('SubscriptionEventType', () => {
- it('should accept all valid event types', () => {
- const types = ['comment', 'mention', 'field_change', 'task', 'approval', 'all'];
- types.forEach(type => {
- expect(() => SubscriptionEventType.parse(type)).not.toThrow();
- });
- });
-
- it('should reject invalid event type', () => {
- expect(() => SubscriptionEventType.parse('unknown')).toThrow();
- expect(() => SubscriptionEventType.parse('')).toThrow();
- });
-});
-
-describe('NotificationChannel', () => {
- it('should accept all valid channels', () => {
- const channels = ['in_app', 'email', 'push', 'slack'];
- channels.forEach(channel => {
- expect(() => NotificationChannel.parse(channel)).not.toThrow();
- });
- });
-
- it('should reject invalid channel', () => {
- expect(() => NotificationChannel.parse('sms')).toThrow();
- });
-});
-
-describe('RecordSubscriptionSchema', () => {
- const minimalSubscription: RecordSubscription = {
- object: 'account',
- recordId: 'rec_123',
- userId: 'user_456',
- createdAt: '2026-01-15T10:00:00Z',
- };
-
- it('should accept minimal subscription with defaults', () => {
- const result = RecordSubscriptionSchema.parse(minimalSubscription);
- expect(result.object).toBe('account');
- expect(result.recordId).toBe('rec_123');
- expect(result.userId).toBe('user_456');
- expect(result.events).toEqual(['all']);
- expect(result.channels).toEqual(['in_app']);
- expect(result.active).toBe(true);
- });
-
- it('should accept full subscription', () => {
- const full: RecordSubscription = {
- object: 'opportunity',
- recordId: 'rec_789',
- userId: 'user_101',
- events: ['comment', 'field_change'],
- channels: ['in_app', 'email'],
- active: true,
- createdAt: '2026-01-15T10:00:00Z',
- };
- const result = RecordSubscriptionSchema.parse(full);
- expect(result.events).toEqual(['comment', 'field_change']);
- expect(result.channels).toEqual(['in_app', 'email']);
- });
-
- it('should accept inactive subscription', () => {
- const result = RecordSubscriptionSchema.parse({
- ...minimalSubscription,
- active: false,
- });
- expect(result.active).toBe(false);
- });
-
- it('should reject without required fields', () => {
- expect(() => RecordSubscriptionSchema.parse({})).toThrow();
- expect(() => RecordSubscriptionSchema.parse({ object: 'account' })).toThrow();
- expect(() => RecordSubscriptionSchema.parse({ object: 'account', recordId: 'rec_1' })).toThrow();
- });
-
- it('should reject invalid datetime format', () => {
- expect(() => RecordSubscriptionSchema.parse({
- ...minimalSubscription,
- createdAt: 'not-a-date',
- })).toThrow();
- });
-
- it('should reject invalid event types in array', () => {
- expect(() => RecordSubscriptionSchema.parse({
- ...minimalSubscription,
- events: ['invalid_event'],
- })).toThrow();
- });
-
- it('should reject invalid notification channels', () => {
- expect(() => RecordSubscriptionSchema.parse({
- ...minimalSubscription,
- channels: ['sms'],
- })).toThrow();
- });
-});
diff --git a/packages/spec/src/data/subscription.zod.ts b/packages/spec/src/data/subscription.zod.ts
deleted file mode 100644
index 25395d5882..0000000000
--- a/packages/spec/src/data/subscription.zod.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-import { z } from 'zod';
-
-/**
- * Subscription Event Type
- * Event types that can be subscribed to for record-level notifications.
- */
-import { lazySchema } from '../shared/lazy-schema';
-export const SubscriptionEventType = z.enum([
- 'comment',
- 'mention',
- 'field_change',
- 'task',
- 'approval',
- 'all',
-]);
-export type SubscriptionEventType = z.infer;
-
-/**
- * Notification Channel
- * Delivery channels for record subscription notifications.
- */
-export const NotificationChannel = z.enum([
- 'in_app',
- 'email',
- 'push',
- 'slack',
-]);
-export type NotificationChannel = z.infer;
-
-/**
- * Record Subscription Schema
- * Defines a user's subscription to record-level notifications.
- * Enables Airtable-style bell icon for record change notifications.
- */
-export const RecordSubscriptionSchema = lazySchema(() => z.object({
- /** Target */
- object: z.string().describe('Object name'),
- recordId: z.string().describe('Record ID'),
-
- /** Subscriber */
- userId: z.string().describe('Subscribing user ID'),
-
- /** Events to subscribe to */
- events: z.array(SubscriptionEventType)
- .default(['all'])
- .describe('Event types to receive notifications for'),
-
- /** Notification channels */
- channels: z.array(NotificationChannel)
- .default(['in_app'])
- .describe('Notification delivery channels'),
-
- /** Active */
- active: z.boolean().default(true).describe('Whether the subscription is active'),
-
- /** Timestamps */
- createdAt: z.string().datetime().describe('Subscription creation timestamp'),
-}));
-export type RecordSubscription = z.infer;
diff --git a/skills/objectstack-ui/references/_index.md b/skills/objectstack-ui/references/_index.md
index 415d5256b8..2041eee2b8 100644
--- a/skills/objectstack-ui/references/_index.md
+++ b/skills/objectstack-ui/references/_index.md
@@ -21,7 +21,7 @@ from `node_modules` — there is no local copy in the skill bundle.
## Transitive dependencies
-- `node_modules/@objectstack/spec/src/data/feed.zod.ts` — Feed Item Type
+- `node_modules/@objectstack/spec/src/data/feed.zod.ts` — Activity-timeline UI config enums.
- `node_modules/@objectstack/spec/src/data/field.zod.ts` — Field Type Enum
- `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification
- `node_modules/@objectstack/spec/src/data/hook-body.zod.ts` — Capability tokens a script body may request.