Skip to content

Commit 107853c

Browse files
feat(spec): IAIConversationService.get / list and IAIService.listPendingActions take the caller's execution context (#23197)
Fixes #23158 Clause-②: yes (widening: an optional parameter on three published store-contract methods) ## What changes The three AI store reads take the caller's execution context as an optional trailing parameter. This is the contract change that objectstack-ai/cloud#2844 Q1 ruling C names (maintainer 「同意」, cloud comment `6138290535`), in the shape triage's direction gives (`6139367236`). | Method | FROM | TO | |---|---|---| | `IAIConversationService.get` | `get(conversationId)` | `get(conversationId, context?: ExecutionContext)` | | `IAIConversationService.list` | `list(options?)` | `list(options?, context?: ExecutionContext)` | | `IAIService.listPendingActions` | `listPendingActions?(filter?)` | `listPendingActions?(filter?, context?: ExecutionContext)` | The TSDoc states the rule an implementation follows: - **Supplied:** the implementation passes it unchanged as the `context` of every engine read the method makes (`BaseEngineOptions.context`), so the read runs as that caller. Under ADR-0096 D5 strict mode an engine read with no principal is refused with 403. - **Omitted:** the read stays the one the implementation made before this parameter existed. - `list`'s `options.userId` is called out as a filter on the owner. It is not the caller's identity. - Whether a caller is admitted to the rows is the stored objects' access and sharing question, which these contracts do not declare. Files: `packages/spec/src/contracts/ai-service.ts` (three signatures, their TSDoc, and one type-only import), a pin file beside it, and `.changeset/23158-ai-store-caller-context.md` (`@objectstack/spec` `minor`, carrying the `Clause-②` line and the FROM → TO). No generated artifact moved (`check:generated` green, see below). No object's `access` or sharing changed. ## Measurements ### H1: "the shape the other store contracts use" Population at `origin/main` `de24373ec`, `packages/spec/src/contracts/*.ts` (test files excluded): the methods that take the caller's context. | Spelling | Contract: methods (lines) | |---|---| | positional `context: ExecutionContext`, required | `sharing-service.ts`: 15 (`:249`–`:588`) · `approval-service.ts`: 12 (`:881`–`:996`) · `share-link-service.ts`: 3 (`:237`, `:245`, `:256`) | | positional `context?: ExecutionContext`, optional | `analytics-service.ts`: 3 (`:312`, `:329`, `:351`) | | positional `context?: SecurityContext` (a `Partial` of `ExecutionContext`) | `security-service.ts`: 20 (`:418`–`:1049`) | | a field in an options bag | `data-engine.ts` `options?: BaseEngineOptions` (`:289`–`:311`) · `objectql-engine.ts:198` · `knowledge-service.ts:43` (`executionContext?`) · `automation-service.ts:709` | The majority is `ExecutionContext` from `../kernel/execution-context.zod.js`, as a positional parameter after the method's own arguments: 33 methods across 4 contracts. In 31 of the 33 it is the last parameter, and in the other 2 one optional bag follows it. `approval-service.ts`'s `listRequests(filter, context)` has the same shape as `list` here. The parameter is optional, as triage directs and as `IAnalyticsService` already does. The import specifier carries `.js`, as 5 of the 7 sibling imports do. **Conflict, stated rather than resolved silently:** the card body's ask said "the same shape the engine's read verbs take", which is the options-bag form. Triage's direction (Zone 1, binding) says "the shape the other store contracts use", and this PR follows triage. The two forms meet at the engine call: the type-level pin below proves `ExecutionContext` is assignable to `BaseEngineOptions['context']`, so an implementation forwards it with no conversion. ### H2: where the objects live, and the owner-reads-own-rows reading: NOT MEASURED here None of the four objects is declared in this repository. On `origin/main` `de24373ec`, a `git grep` for the declaration spelling `name: 'OBJECT'` returns nothing for `ai_conversations`, `ai_messages`, `ai_pending_actions` or `ai_agent_memories`. The only hits are test fixtures in `plugin-sharing` and name references in the share-link routes. The control leg is the same grep for `name: 'sys_user'`, which hits `packages/platform-objects/src/identity/sys-user.object.ts:46`. This repository's liveness ledger (`packages/spec/liveness/agent.json:96`) cites the store at `cloud @ef5a4344: packages/service-ai/src/agent-memory.ts#AgentMemoryStore — the store, over the object ai_agent_memories (access private, no API)`. I did not read that file directly. So triage's measurement ("with a caller threaded through, does an ordinary member read their own rows on these `access: private` objects?") cannot run in this repository. It can run in objectstack-ai/cloud's `service-ai`, with this contract at cloud's pin. The cloud card that switches the 18 reads back to the caller's identity is the natural place to measure it. **No sharing card is filed on an unmeasured premise.** If that measurement shows an owner is refused, the sharing question is filed then, from there. ### H3: the pins, without an in-repo implementer `git grep` on `origin/main` finds no implementer of `IAIConversationService` or of `IAIService.listPendingActions` in this repository. The only users are `ai-service.test.ts`'s mocks and a type reference in `packages/runtime/src/domains/ai.ts:67`, which never calls these methods. So the pins in `packages/spec/src/contracts/ai-store-caller-context.pin.test.ts` are contract-level: - **Type level** (compiled by `check:test-typecheck` under `tsconfig.test.json`): - each method's trailing parameter is exactly `ExecutionContext | undefined`, and nothing follows it (arity `1 | 2` / `0 | 1 | 2`); - `ExecutionContext` is assignable to the non-null `BaseEngineOptions['context']`; - an implementation written against the shorter signatures still satisfies each method type; - calls without the context compile. - **Runtime:** a minimal in-test implementation, over a recording engine port typed as `IDataEngine`'s `find` / `findOne` (a `Pick`), is written to the TSDoc's forwarding rule. - With a member context, every engine read (`get` makes two, `ai_conversations` then `ai_messages`) carries it as `options.context`, by reference, and the real `BaseEngineOptionsSchema.safeParse` accepts those options. - Without one, each engine read gets no options argument at all, which is the call the shorter signature made. - The runtime half pins the rule as executable reference. It cannot pin `service-ai`, and that implementer's own conformance lives with it. **Reverse verification (ablation), one per method, from the committed state** (the HEAD blob of `ai-service.ts` is `84b68834da23`). Each leg ran through `node scripts/ablation-replace.mjs`: the anchor hit 1 → 0, the blob changed, and on restore the blob equalled HEAD and `git diff HEAD` was empty. No `dist/` rebuild was needed, because the pin imports `./ai-service` and `../data/data-engine.zod` by relative path, so the program reads `src/`. Measured with the spec test-layer program (`tsc --noEmit -p tsconfig.test.json`): | Leg | Pin-file errors | Program total | What went red | |---|---|---|---| | baseline (HEAD) | 0 | 246 (the ledger's 246) | none | | `get` without `context` | 7 | 253 | `GetTakesTheCallerContext` and `GetArity` (TS2344), TS2493, the in-test `get` (TS2322 / TS7006), the two-argument call (TS2554) | | `list` without `context` | 7 | 253 | `ListTakesTheCallerContext` and `ListArity`, the same classes | | `listPendingActions` without `context` | 7 | 253 | `ListPendingActionsTakesTheCallerContext` and `ListPendingActionsArity`, the same classes | The observed direction is turning red, at the type layer. The runtime half stays green under each ablation, because types are erased at run time. That is expected: the contract is a type, and the type half is what fails. ### H4: the typecheck debt ledger The pinned entry `src/contracts/ai-service.test.ts`: `TS2739 … missing … getTurnState, update` is unmoved. A mock written against the shorter `get` / `list` stays assignable, so no new signature arrives. `check:test-typecheck` is green with the ledger untouched: `52 file(s) / 246 error(s) / 135 pinned signature(s) held`. The new pin file compiles with zero errors. `packages/spec/test-typecheck-debt.json` is not in this diff. ## Verification Readings taken at HEAD `c128fd10a`: this branch after merging `origin/main` `32fa1942e`, which touched `packages/spec` in disjoint files. - `pnpm --filter @objectstack/spec build`: `VERDICT command-exit 0`. The rebuilt `dist/contracts/index.d.ts` carries the three new signatures (`:5745`, `:6084`, `:6094`). - `pnpm --filter @objectstack/spec check:generated`: exit 0, `All 14 generated artifacts are up to date`. That includes `check:api-surface` and `check:export-origins` (no export added or removed) and `check:test-typecheck`. - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2 src/contracts/ai-store-caller-context.pin.test.ts src/contracts/ai-service.test.ts`: `Test Files 2 passed (2)`, `Tests 27 passed (27)`. - The full spec suite, `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2`: `Test Files 662 passed (662)`, `Tests 20114 passed | 1 todo`. - `pnpm --filter @objectstack/spec typecheck`: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived 85 commands. Each line was run with its exit code captured before any pipe. - 82 exited 0 on the first pass. - 2 exited 3 (PREREQUISITE NOT MET) and went green after their prerequisites were built. `check:doc-formula-expressions` passed after `turbo run build --filter=@objectstack/formula --filter=@objectstack/lint`: `22 record-scoped formula example(s) across 474 files / 1391 TS blocks judged clean`. `check:lean-entry-closure` passed after building `@objectstack/objectql`: `Admitted set held exactly (15 packages)`. - 1 is **NOT MEASURED**: `check:dual-build-cjs-loads`, which needs every package's `dist/`, so a whole-workspace build. As a declared narrowing, all 19 `@objectstack/spec` CJS require entries the gate lists (`--list`) load in a fresh `node` with 0 failures. The diff adds only a type-only import, which is erased from the emitted JS. - `--ran` reconciliation: `85 derived famil(ies) accounted for — 84 run, 1 NOT-MEASURED`. - ESLint, narrowed and proven: `pnpm exec eslint --no-inline-config --format json` over the two changed `.ts` files reports 2 files, 0 errors and 0 warnings. Both files are in the config's population (`--print-config` resolves rules for each). The config enables no type-aware linting (`eslint.config.mjs:327`–`:328`: no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. The full `pnpm lint` is CI's run. - Consumers: none in this repository call the three methods (H3). The public surface widens by an optional parameter only, so no import-side test is owed here. ## Acceptance notes - **The card body's asks 2 and 3 are not in this PR.** Ask 2 (owner reads of the four objects) and ask 3 (a non-owner refused, the owner admitted) concern the objects, which are declared in objectstack-ai/cloud. Triage's direction keeps sharing out of this card ("⛔ no sharing widened as a rider") and routes it to a card filed only after the measurement in H2. That measurement can only run where the objects are declared. - **Other contract reads with no caller context**, noted and not filed. `IAIConversationService.getTurnState` reads the turn's `ai_messages`, and `addMessage` / `update` answer with the conversation. None of them takes a caller context. The ruling and triage name exactly three methods, so this PR changes those three. Carrier: the cloud card that switches the 18 reads back, which will see whether any of these is among the reads D5 refuses. --- _Generated by [Claude Code](https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent c6139f2 commit 107853c

3 files changed

Lines changed: 292 additions & 15 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`IAIConversationService.get` / `list` and `IAIService.listPendingActions` take the caller's execution context as an optional trailing parameter (ADR-0096 D5)
6+
7+
Clause-②: yes (widening: an optional parameter on three published store-contract methods)
8+
9+
- **FROM → TO.**
10+
- `get(conversationId)` → `get(conversationId, context?: ExecutionContext)`
11+
- `list(options?)` → `list(options?, context?: ExecutionContext)`
12+
- `listPendingActions?(filter?)` → `listPendingActions?(filter?, context?: ExecutionContext)`
13+
- **What an implementation does with it.** When a caller supplies `context`, the implementation passes it unchanged as the `context` of every engine read the method makes (`BaseEngineOptions.context`), so the read runs as that caller. Under ADR-0096 D5 strict mode an engine read with no principal is refused with 403, so without a caller an implementation can only read as system. The type and the position are the ones `ISharingService`, `IApprovalService`, `IShareLinkService` and `IAnalyticsService` already use.
14+
- **Nothing to migrate.** The parameter is optional. A caller that omits it gets the read the implementation made before, and an implementation written against the shorter signatures still satisfies both interfaces. To read as the caller, pass the request's `ExecutionContext` and forward it to your engine reads.
15+
- `list`'s `options.userId` stays a filter on the conversation's owner. It is not the caller's identity.
16+
- Whether an ordinary member is admitted to their own rows depends on the stored objects' access and sharing, which these contracts do not declare.

‎packages/spec/src/contracts/ai-service.ts‎

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import type {
5353
ToolSet,
5454
} from 'ai';
5555
import type { z } from 'zod';
56+
import type { ExecutionContext } from '../kernel/execution-context.zod.js';
5657

5758
/**
5859
* @deprecated Use `ModelMessage` from `ai` instead.
@@ -443,13 +444,30 @@ export interface IAIService {
443444
/**
444445
* Inbox query: list pending (or filtered) action proposals. Used by
445446
* Studio's pending-actions view.
446-
*/
447-
listPendingActions?(filter?: {
448-
status?: PendingActionStatus | PendingActionStatus[];
449-
conversationId?: string;
450-
objectName?: string;
451-
limit?: number;
452-
}): Promise<PendingActionRow[]>;
447+
*
448+
* @param filter - Status, conversation, object and row-count filters
449+
* @param context - The caller's execution context, the trailing
450+
* parameter the other store contracts take. When it is
451+
* supplied, the implementation passes it unchanged as the
452+
* `context` of the engine read it makes
453+
* (`BaseEngineOptions.context`), so the read runs as that
454+
* caller: under ADR-0096 D5 strict mode an engine read
455+
* with no principal is refused with 403, so without it an
456+
* implementation can only read as system. When it is
457+
* omitted, the read is the one the implementation made
458+
* before this parameter existed. Whether a caller is
459+
* admitted to the rows depends on the stored object's
460+
* access and sharing, which this contract does not declare.
461+
*/
462+
listPendingActions?(
463+
filter?: {
464+
status?: PendingActionStatus | PendingActionStatus[];
465+
conversationId?: string;
466+
objectName?: string;
467+
limit?: number;
468+
},
469+
context?: ExecutionContext,
470+
): Promise<PendingActionRow[]>;
453471
}
454472

455473
/** Lifecycle of a pending action proposal. */
@@ -749,6 +767,25 @@ export interface MessageObservability {
749767
* IAIConversationService - Manages persistent AI conversations
750768
*
751769
* Provides CRUD operations for conversations and their messages.
770+
*
771+
* ## The caller's execution context on reads (ADR-0096 D5)
772+
*
773+
* {@link IAIConversationService.get} and {@link IAIConversationService.list}
774+
* take an optional trailing `context`: the caller's {@link ExecutionContext},
775+
* the type and position the other store contracts use (`ISharingService`,
776+
* `IApprovalService`, `IShareLinkService`, `IAnalyticsService`).
777+
*
778+
* - **Supplied:** the implementation passes it unchanged as the `context` of
779+
* every engine read the method makes (`BaseEngineOptions.context` on
780+
* `IDataEngine.find` / `findOne`), so the read runs as that caller. Under
781+
* ADR-0096 D5 strict mode an engine read with no principal is refused with
782+
* 403, so without a caller an implementation can only read as system.
783+
* - **Omitted:** the read is the one the implementation made before this
784+
* parameter existed, and a caller written against the shorter signature
785+
* keeps compiling.
786+
*
787+
* Whether a caller is admitted to the rows depends on the stored objects'
788+
* access and sharing, which this contract does not declare.
752789
*/
753790
export interface IAIConversationService {
754791
/**
@@ -770,21 +807,30 @@ export interface IAIConversationService {
770807
* ObjectQL data endpoint directly — that's the canonical query layer.
771808
*
772809
* @param conversationId - Conversation identifier
810+
* @param context - The caller's execution context, passed to every
811+
* engine read this method makes (see the interface doc)
773812
* @returns The conversation, or null if not found
774813
*/
775-
get(conversationId: string): Promise<AIConversation | null>;
814+
get(conversationId: string, context?: ExecutionContext): Promise<AIConversation | null>;
776815

777816
/**
778817
* List conversations with optional filters
779-
* @param options - Filter and pagination options
818+
* @param options - Filter and pagination options. `userId` filters on
819+
* the conversation's owner; it is not the caller's
820+
* identity and does not stand in for `context`.
821+
* @param context - The caller's execution context, passed to every
822+
* engine read this method makes (see the interface doc)
780823
* @returns Array of matching conversations
781824
*/
782-
list(options?: {
783-
userId?: string;
784-
agentId?: string;
785-
limit?: number;
786-
cursor?: string;
787-
}): Promise<AIConversation[]>;
825+
list(
826+
options?: {
827+
userId?: string;
828+
agentId?: string;
829+
limit?: number;
830+
cursor?: string;
831+
},
832+
context?: ExecutionContext,
833+
): Promise<AIConversation[]>;
788834

789835
/**
790836
* Add a message to a conversation
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The caller's execution context on the AI store contracts' three reads:
5+
* `IAIConversationService.get` / `list` and `IAIService.listPendingActions`
6+
* (ADR-0096 D5: an engine read with no principal is refused in strict mode, so
7+
* an implementation that receives no caller can only read as system).
8+
*
9+
* No implementation of these contracts lives in this repository — the
10+
* implementer is `service-ai`, in the cloud repository — so both halves are
11+
* pinned at the contract:
12+
*
13+
* 1. **Type level**, compiled by `check:test-typecheck` under
14+
* `tsconfig.test.json`:
15+
* - each method's trailing parameter is exactly
16+
* `ExecutionContext | undefined`, the type and position the other store
17+
* contracts use (`ISharingService`, `IApprovalService`,
18+
* `IShareLinkService`, `IAnalyticsService`), and nothing follows it;
19+
* - `ExecutionContext` is assignable to `BaseEngineOptions['context']`, so
20+
* an implementation forwards it to its engine read with no conversion;
21+
* - an implementation written against the shorter signatures still
22+
* satisfies the contracts, and calls without the context still compile
23+
* (the "omitted" cases below are written in that form).
24+
* 2. **Runtime**, on a minimal in-test implementation over a recording engine
25+
* port, written to the forwarding rule the TSDoc states: a supplied context
26+
* reaches every engine read the method makes as `options.context`, by
27+
* reference, and the real `BaseEngineOptionsSchema` accepts it; an omitted
28+
* context leaves each engine read exactly as the shorter signature made it
29+
* — no options argument at all.
30+
*
31+
* ⛔ Not pinned here: whether an ordinary member is admitted to their own rows.
32+
* That is the stored objects' access and sharing, declared with the objects in
33+
* the implementer's repository, not in this contract.
34+
*/
35+
36+
import { describe, expect, it } from 'vitest';
37+
38+
import type { ExecutionContext } from '../kernel/execution-context.zod';
39+
import { BaseEngineOptionsSchema, type BaseEngineOptions } from '../data/data-engine.zod';
40+
import type { AIConversation, IAIConversationService, IAIService, PendingActionRow } from './ai-service';
41+
import type { IDataEngine } from './data-engine';
42+
43+
/** Type-level identity: true iff A and B are the same type. */
44+
type Eq<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
45+
/** Compile error when the argument is not `true`. */
46+
type Assert<T extends true> = T;
47+
48+
type GetParams = Parameters<IAIConversationService['get']>;
49+
type ListParams = Parameters<IAIConversationService['list']>;
50+
type ListPendingParams = Parameters<NonNullable<IAIService['listPendingActions']>>;
51+
52+
// Exported deliberately: an unread alias is TS6196, and a pin no program compiles is no pin.
53+
54+
/** The trailing parameter is the caller's `ExecutionContext`, optional. */
55+
export type GetTakesTheCallerContext = Assert<Eq<GetParams[1], ExecutionContext | undefined>>;
56+
export type ListTakesTheCallerContext = Assert<Eq<ListParams[1], ExecutionContext | undefined>>;
57+
export type ListPendingActionsTakesTheCallerContext = Assert<Eq<ListPendingParams[1], ExecutionContext | undefined>>;
58+
59+
/** It is the last parameter, and the method's own argument still comes first and stays optional where it was. */
60+
export type GetArity = Assert<Eq<GetParams['length'], 1 | 2>>;
61+
export type ListArity = Assert<Eq<ListParams['length'], 0 | 1 | 2>>;
62+
export type ListPendingActionsArity = Assert<Eq<ListPendingParams['length'], 0 | 1 | 2>>;
63+
64+
/** Forwardable without conversion: the engine read's `options.context` accepts the contract's type. */
65+
export type ForwardsToTheEngineRead = Assert<
66+
[ExecutionContext] extends [NonNullable<BaseEngineOptions['context']>] ? true : false
67+
>;
68+
69+
/** An implementation written against the shorter signatures still satisfies the contracts. */
70+
export const shorterGet: IAIConversationService['get'] = async (_conversationId: string) => null;
71+
export const shorterList: IAIConversationService['list'] = async (_options?: ListParams[0]) => [];
72+
export const shorterListPendingActions: NonNullable<IAIService['listPendingActions']> = async (
73+
_filter?: ListPendingParams[0],
74+
) => [];
75+
76+
// ---------------------------------------------------------------------------
77+
// Runtime half: a minimal implementation written to the forwarding rule
78+
// ---------------------------------------------------------------------------
79+
80+
interface RecordedRead {
81+
verb: 'find' | 'findOne';
82+
objectName: string;
83+
/** Every argument after the object name, exactly as the implementation passed it. */
84+
rest: unknown[];
85+
}
86+
87+
function recordingEngine(rows: Record<string, unknown>[]) {
88+
const reads: RecordedRead[] = [];
89+
const engine: Pick<IDataEngine, 'find' | 'findOne'> = {
90+
async find(objectName: string, ...rest: unknown[]) {
91+
reads.push({ verb: 'find', objectName, rest });
92+
return rows;
93+
},
94+
async findOne(objectName: string, ...rest: unknown[]) {
95+
reads.push({ verb: 'findOne', objectName, rest });
96+
return rows[0] ?? null;
97+
},
98+
};
99+
return { engine, reads };
100+
}
101+
102+
/** The forwarding rule: a supplied context becomes the engine read's options; an omitted one adds nothing. */
103+
const callerOptions = (context?: ExecutionContext): [] | [BaseEngineOptions] => (context ? [{ context }] : []);
104+
105+
function toConversation(row: Record<string, unknown>): AIConversation {
106+
return {
107+
id: String(row.id),
108+
userId: row.user_id as string | undefined,
109+
messages: [],
110+
createdAt: String(row.created_at),
111+
updatedAt: String(row.updated_at),
112+
};
113+
}
114+
115+
function conversationStore(engine: Pick<IDataEngine, 'find' | 'findOne'>): Pick<IAIConversationService, 'get' | 'list'> {
116+
return {
117+
async get(conversationId, context) {
118+
const row = await engine.findOne('ai_conversations', { where: { id: conversationId } }, ...callerOptions(context));
119+
if (!row) return null;
120+
await engine.find('ai_messages', { where: { conversation_id: conversationId } }, ...callerOptions(context));
121+
return toConversation(row);
122+
},
123+
async list(options, context) {
124+
const where: Record<string, unknown> = {};
125+
if (options?.userId) where.user_id = options.userId;
126+
if (options?.agentId) where.agent_id = options.agentId;
127+
const rows = await engine.find('ai_conversations', { where, limit: options?.limit }, ...callerOptions(context));
128+
return rows.map(toConversation);
129+
},
130+
};
131+
}
132+
133+
function pendingActionInbox(
134+
engine: Pick<IDataEngine, 'find' | 'findOne'>,
135+
): Required<Pick<IAIService, 'listPendingActions'>> {
136+
return {
137+
async listPendingActions(filter, context) {
138+
const where: Record<string, unknown> = {};
139+
if (filter?.status) where.status = filter.status;
140+
if (filter?.conversationId) where.conversation_id = filter.conversationId;
141+
const rows = await engine.find('ai_pending_actions', { where, limit: filter?.limit }, ...callerOptions(context));
142+
return rows as unknown as PendingActionRow[];
143+
},
144+
};
145+
}
146+
147+
const MEMBER: ExecutionContext = { userId: 'usr_member', tenantId: 'org_acme', positions: ['member'] };
148+
const CONVERSATION_ROW = { id: 'conv_1', user_id: 'usr_member', created_at: '2026-10-13T00:00:00Z', updated_at: '2026-10-13T00:00:00Z' };
149+
150+
/** The options a recorded read carried, or `undefined` when it carried none. */
151+
const optionsOf = (read: RecordedRead) => read.rest[1] as BaseEngineOptions | undefined;
152+
153+
describe('AI store contracts: the caller context reaches the engine read', () => {
154+
it('get: a supplied context is the context of every engine read it makes, by reference', async () => {
155+
const { engine, reads } = recordingEngine([CONVERSATION_ROW]);
156+
const conversation = await conversationStore(engine).get('conv_1', MEMBER);
157+
158+
expect(conversation?.id).toBe('conv_1');
159+
expect(reads.map((r) => r.objectName)).toEqual(['ai_conversations', 'ai_messages']);
160+
for (const read of reads) {
161+
expect(optionsOf(read)?.context).toBe(MEMBER);
162+
expect(BaseEngineOptionsSchema.safeParse(optionsOf(read)).success).toBe(true);
163+
}
164+
});
165+
166+
it('list: a supplied context reaches the engine read, and `userId` stays a filter', async () => {
167+
const { engine, reads } = recordingEngine([CONVERSATION_ROW]);
168+
await conversationStore(engine).list({ userId: 'usr_member' }, MEMBER);
169+
170+
expect(reads).toHaveLength(1);
171+
expect(reads[0].rest[0]).toEqual({ where: { user_id: 'usr_member' }, limit: undefined });
172+
expect(optionsOf(reads[0])?.context).toBe(MEMBER);
173+
expect(BaseEngineOptionsSchema.safeParse(optionsOf(reads[0])).success).toBe(true);
174+
});
175+
176+
it('listPendingActions: a supplied context reaches the engine read', async () => {
177+
const { engine, reads } = recordingEngine([]);
178+
await pendingActionInbox(engine).listPendingActions({ status: 'pending' }, MEMBER);
179+
180+
expect(reads).toHaveLength(1);
181+
expect(reads[0].objectName).toBe('ai_pending_actions');
182+
expect(optionsOf(reads[0])?.context).toBe(MEMBER);
183+
expect(BaseEngineOptionsSchema.safeParse(optionsOf(reads[0])).success).toBe(true);
184+
});
185+
});
186+
187+
describe('AI store contracts: an omitted context keeps the shorter call', () => {
188+
it('get: each engine read carries no options argument', async () => {
189+
const { engine, reads } = recordingEngine([CONVERSATION_ROW]);
190+
await conversationStore(engine).get('conv_1');
191+
192+
expect(reads).toHaveLength(2);
193+
for (const read of reads) expect(read.rest).toHaveLength(1);
194+
});
195+
196+
it('list: the engine read carries no options argument, with or without filters', async () => {
197+
const { engine, reads } = recordingEngine([]);
198+
const store = conversationStore(engine);
199+
await store.list({ userId: 'usr_member' });
200+
await store.list();
201+
202+
expect(reads).toHaveLength(2);
203+
for (const read of reads) expect(read.rest).toHaveLength(1);
204+
});
205+
206+
it('listPendingActions: the engine read carries no options argument', async () => {
207+
const { engine, reads } = recordingEngine([]);
208+
const inbox = pendingActionInbox(engine);
209+
await inbox.listPendingActions({ status: 'pending' });
210+
await inbox.listPendingActions();
211+
212+
expect(reads).toHaveLength(2);
213+
for (const read of reads) expect(read.rest).toHaveLength(1);
214+
});
215+
});

0 commit comments

Comments
 (0)