@segmentstream-cloud/ontology 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +35 -10
  2. package/dist/agent-chat.d.ts +75 -0
  3. package/dist/agent-chat.js +176 -0
  4. package/dist/analyze.d.ts +6 -2
  5. package/dist/analyze.js +29 -18
  6. package/dist/authoring.d.ts +6 -3
  7. package/dist/authoring.js +6 -3
  8. package/dist/index.d.ts +4 -2
  9. package/dist/index.js +2 -1
  10. package/dist/permissions.d.ts +11 -2
  11. package/dist/permissions.js +15 -3
  12. package/dist/replication/baseline.js +1 -1
  13. package/dist/replication/model.js +2 -2
  14. package/dist/replication/schema.js +1 -1
  15. package/dist/runtime/agents.d.ts +1 -0
  16. package/dist/runtime/agents.js +160 -0
  17. package/dist/runtime/generated-actions.d.ts +2 -1
  18. package/dist/runtime/generated-actions.js +18 -13
  19. package/dist/service/agent/channel.d.ts +16 -0
  20. package/dist/service/agent/channel.js +88 -0
  21. package/dist/service/agent/protocol.d.ts +14 -0
  22. package/dist/service/agent/protocol.js +26 -0
  23. package/dist/service/agent/server.d.ts +2 -1
  24. package/dist/service/agent/server.js +24 -2
  25. package/dist/service/aggregate-platform.d.ts +5 -0
  26. package/dist/service/aggregate-platform.js +1 -1
  27. package/dist/service/config.d.ts +10 -0
  28. package/dist/service/config.js +8 -0
  29. package/dist/service/handlers.d.ts +2 -1
  30. package/dist/service/handlers.js +2 -2
  31. package/dist/service/mcp/server.d.ts +3 -0
  32. package/dist/service/mcp/server.js +1 -1
  33. package/dist/service/operations.d.ts +6 -2
  34. package/dist/service/operations.js +13 -5
  35. package/dist/service/runtime-contract.d.ts +26 -0
  36. package/dist/service/service.js +7 -1
  37. package/package.json +1 -1
package/README.md CHANGED
@@ -143,7 +143,8 @@ export default defineOntology({
143
143
  - `rel.one` and `rel.many` declare relationships. Between stored entities, `rel.one` adds a
144
144
  key and `rel.many` a join table. With a warehouse-backed end, `rel.one` declares one `on`
145
145
  pair and is matched by value. `User` is the built-in entity for app users and service
146
- principals.
146
+ principals; `AgentChat` (`agent_chat`) is the built-in entity for the group chats of the
147
+ app's agent (see [Agents](#agents)). Every ontology includes both.
147
148
  - `role(name, { description, permissions })` declares a role: a named set of permissions.
148
149
  `member` is built in: every active app user holds it, with the permissions of
149
150
  `defineOntology({ member })`.
@@ -162,7 +163,9 @@ export default defineOntology({
162
163
 
163
164
  A permission is `<entity>:<verb>`. The verbs are `read`, the generated `create`, `update`,
164
165
  `delete`, `link` and `unlink`, and the names of authored actions. A role may use `*` for the
165
- entity, the verb or both: `lead:*`, `*:read`, `*:*`.
166
+ entity, the verb or both: `lead:*`, `*:read`, `*:*`. An entity wildcard never covers the
167
+ built-in `agent_chat`: reading or changing it needs a permission that names it, such as
168
+ `agent_chat:read`.
166
169
 
167
170
  - Reads require `<entity>:read`: lists, queries, records, history, both entities of a
168
171
  relationship traversal, and every entity an aggregate touches. `include` and a filter through a relationship require `read` on
@@ -216,6 +219,7 @@ one is missing or invalid, and never repeats the database URL.
216
219
  | `SEGMENTSTREAM_APP_CREDENTIAL` | Optional. The workspace app credential; without it, aggregates answer 503. |
217
220
  | `SEGMENTSTREAM_RUNTIME_OIDC_ISSUER` | Optional. The team's Vercel OIDC issuer, for the app's agent. |
218
221
  | `SEGMENTSTREAM_RUNTIME_OIDC_AUDIENCE` | Optional, set with the issuer. The audience of the team's tokens. |
222
+ | `VERCEL_PROJECT_PRODUCTION_URL` | Set by Vercel. With the runtime pair, where `agent_chat.send_message` reaches the agent. |
219
223
 
220
224
  The platform sets these variables in the workspace deployment, the Neon Auth pair once the
221
225
  workspace has Neon Auth and the runtime pair on deployments with an agent. With the runtime
@@ -268,14 +272,15 @@ The agent acts for a member by adding `X-SegmentStream-Act-As: <user id>` on any
268
272
  member, as if they had called. A member who is inactive or has no live pairing is a 403.
269
273
  Without the header the agent may call only the agent routes:
270
274
 
271
- | Route | Caller | Does |
272
- | ------------------------------------------------------------- | ------ | -------------------------------------------------------------------- |
273
- | `POST /api/ontology/agent/pairings` | agent | `{ provider, external_id, display? }` → `{ code, expires_at }` |
274
- | `GET /api/ontology/agent/pairings/{code}` | member | `{ provider, display, expires_at }`, for the connect page |
275
- | `POST /api/ontology/agent/pairings/{code}/approve` | member | Pairs the account with the member → `{ identity }` |
276
- | `GET /api/ontology/agent/identities/{provider}/{external_id}` | agent | `{ user: { id, name }, roles }`; 404 unpaired, 403 revoked, inactive |
277
- | `GET /api/ontology/agent/identities` | member | `{ identities }`: the member's live identities |
278
- | `DELETE /api/ontology/agent/identities/{id}` | member | Revokes one of them: 204 |
275
+ | Route | Caller | Does |
276
+ | ------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------- |
277
+ | `POST /api/ontology/agent/pairings` | agent | `{ provider, external_id, display? }` → `{ code, expires_at }` |
278
+ | `GET /api/ontology/agent/pairings/{code}` | member | `{ provider, display, expires_at }`, for the connect page |
279
+ | `POST /api/ontology/agent/pairings/{code}/approve` | member | Pairs the account with the member → `{ identity }` |
280
+ | `GET /api/ontology/agent/identities/{provider}/{external_id}` | agent | `{ user: { id, name }, roles }`; 404 unpaired, 403 revoked, inactive |
281
+ | `GET /api/ontology/agent/identities` | member | `{ identities }`: the member's live identities |
282
+ | `DELETE /api/ontology/agent/identities/{id}` | member | Revokes one of them: 204 |
283
+ | `POST /api/ontology/agent/chats/seen` | agent | `{ provider, externalId, title, memberId?, leftMemberId?, agentLeft?, migratedFrom? }` → `{ id, title, purpose, whenToUse }` |
279
284
 
280
285
  A pairing code is eight characters without look-alikes (no 0, O, 1, I or L), stored as its
281
286
  SHA-256, valid ten minutes and used once. Each instance allows a client address 60 new
@@ -286,6 +291,26 @@ agent, even acting as a member, so only members pair. Pairings and identities li
286
291
  runtime tables `_agent_pairings` and `_agent_identities`; the migration creates them in a
287
292
  database an earlier SDK migrated.
288
293
 
294
+ The built-in entity `agent_chat` keeps the group chats the agent is in. The agent reports each
295
+ message it sees there to `chats/seen`, which creates the chat or keeps its title current,
296
+ records `lastMessageAt` at most every 15 minutes, links a paired, active writer to
297
+ `members` and unlinks `leftMemberId`, a paired member who left, when linked, or every member
298
+ with `agentLeft: true`, when the agent itself left (the chat stays). With `migratedFrom`, the
299
+ old id of a group that became a supergroup, it moves the old record to `externalId`, or, when
300
+ both are recorded, moves the members and a missing `purpose` and `whenToUse` to the new one and
301
+ deletes the old one. `provider`, `externalId`, `title` and `lastMessageAt` are
302
+ managed. Members set `purpose` and `whenToUse` with the generated
303
+ `agent_chat.update`, the chat's only generated action. The built-in action
304
+ `agent_chat.send_message` (`{ message, chat?, person? }`, `message` at most 4000 characters) asks the agent to write to the chat
305
+ given, else to the person's one chat, else to their paired private chat; a person in several
306
+ chats is refused with the candidates, or only their number without `agent_chat:read`. The service posts the message to the agent at
307
+ `https://<VERCEL_PROJECT_PRODUCTION_URL>/eve/v1/telegram/messages` with the deployment's OIDC
308
+ token, naming the requester with their `id`, `name`, `kind`, `roles` and `actAs`, true for a
309
+ member with a live pairing, whom the agent's turn can act as; without the runtime
310
+ pair, or when the agent refuses, the action is rejected with the reason. Both actions need their own permission (`agent_chat:update`,
311
+ `agent_chat:send_message`), which no role holds until the workspace grants it, and reading
312
+ chats needs `agent_chat:read` by name: `*:read` and `*:*` do not cover `agent_chat`.
313
+
289
314
  ## The `segmentstream-ontology` binary
290
315
 
291
316
  Run the binary in the `ontology/` folder. The entry defaults to `./index.ts`. The binary
@@ -0,0 +1,75 @@
1
+ import { Context, Effect, Schema } from "effect";
2
+ import { ActionRejected } from "./errors.js";
3
+ /** The only channel the agent delivers to so far, and the provider of its chats. */
4
+ export declare const TELEGRAM = "telegram";
5
+ /**
6
+ * What `agent_chat.send_message` asks the agent to deliver: the body of the agent's
7
+ * `POST /eve/v1/telegram/messages`. A Telegram private chat's id is the person's Telegram user id.
8
+ */
9
+ export interface AgentMessage {
10
+ readonly target: {
11
+ readonly kind: "group" | "private";
12
+ readonly provider: typeof TELEGRAM;
13
+ readonly chatId: string;
14
+ };
15
+ /** The request for the agent, written as an instruction to it. */
16
+ readonly message: string;
17
+ /** Whom the agent should address, when the caller named someone. */
18
+ readonly person: {
19
+ readonly id: string;
20
+ readonly name: string;
21
+ } | null;
22
+ /** Who asked: the principal that ran the action. */
23
+ readonly requester: {
24
+ readonly id: string;
25
+ readonly name: string;
26
+ readonly kind: "member" | "service";
27
+ /** The role names the principal holds, which pick the model of a session the request starts. */
28
+ readonly roles: ReadonlyArray<string>;
29
+ /**
30
+ * Whether the agent can act as the requester in the turn: a member with a live paired identity.
31
+ * Otherwise the turn runs without a member and has no ontology.
32
+ */
33
+ readonly actAs: boolean;
34
+ };
35
+ }
36
+ declare const AgentChannel_base: Context.ServiceClass<AgentChannel, "@segmentstream-cloud/ontology/AgentChannel", {
37
+ /** The Telegram user id of the member's live paired identity: their private chat's id. */
38
+ readonly privateChat: (userId: string) => Effect.Effect<string | undefined>;
39
+ /** Hands the message to the agent; a refusal or failure explains itself to the caller. */
40
+ readonly send: (message: AgentMessage) => Effect.Effect<void, ActionRejected>;
41
+ }>;
42
+ /**
43
+ * The workspace's agent, as `agent_chat.send_message` reaches it. The ontology service provides it to
44
+ * actions only on deployments with an agent; without it, the action is refused.
45
+ */
46
+ export declare class AgentChannel extends AgentChannel_base {
47
+ }
48
+ /**
49
+ * The built-in agent chat: a group chat the workspace's agent is in. Every ontology includes it,
50
+ * after `User`. The agent records each chat it sees and the members it sees writing there, through
51
+ * `POST /api/ontology/agent/chats/seen`; members say what a chat is for with the generated `update`,
52
+ * which changes only `purpose` and `whenToUse`. `send_message` asks the agent to write to a chat.
53
+ */
54
+ export declare const AgentChat: import("./entity.js").Entity<"agent_chat", {
55
+ readonly provider: import("./property.js").Property<string, false, false>;
56
+ readonly externalId: import("./property.js").Property<string, false, false>;
57
+ readonly title: import("./property.js").Property<string, false, false>;
58
+ readonly lastMessageAt: Schema.optionalKey<import("./property.js").Property<string, false, false>>;
59
+ readonly purpose: Schema.optionalKey<import("./property.js").Property<string, false, false>>;
60
+ readonly whenToUse: Schema.optionalKey<import("./property.js").Property<string, false, false>>;
61
+ }, {
62
+ readonly members: import("./relationship.js").ManyRelationship;
63
+ }, {
64
+ readonly sendMessage: import("./action.js").Action<Schema.Struct<{
65
+ readonly message: import("./property.js").Property<string, false, false>;
66
+ readonly chat: Schema.optionalKey<Schema.Codec<string, string, never, never>>;
67
+ readonly person: Schema.optionalKey<Schema.Codec<string, string, never, never>>;
68
+ }>, {
69
+ chat: string;
70
+ target: "group" | "private";
71
+ delivered: true;
72
+ }>;
73
+ }, Readonly<Record<never, never>>>;
74
+ export type AgentChat = typeof AgentChat.Type;
75
+ export {};
@@ -0,0 +1,176 @@
1
+ import { Context, Effect, Option, Schema } from "effect";
2
+ import { action } from "./action.js";
3
+ import { entity } from "./entity.js";
4
+ import { ActionRejected } from "./errors.js";
5
+ import { isAllowed, permissionOf, READ_VERB } from "./permissions.js";
6
+ import { p } from "./property.js";
7
+ import { rel } from "./relationship.js";
8
+ import { User } from "./user.js";
9
+ /** The only channel the agent delivers to so far, and the provider of its chats. */
10
+ export const TELEGRAM = "telegram";
11
+ /**
12
+ * The workspace's agent, as `agent_chat.send_message` reaches it. The ontology service provides it to
13
+ * actions only on deployments with an agent; without it, the action is refused.
14
+ */
15
+ export class AgentChannel extends Context.Service()("@segmentstream-cloud/ontology/AgentChannel") {
16
+ }
17
+ /** A group chat as the agent's target; the agent delivers only to Telegram so far. */
18
+ function groupTarget(chat) {
19
+ return chat.provider === TELEGRAM
20
+ ? Effect.succeed({ kind: "group", provider: TELEGRAM, chatId: chat.externalId })
21
+ : Effect.fail(new ActionRejected({
22
+ message: `The agent delivers only to Telegram chats; ${chat.title} is on ${chat.provider}.`,
23
+ }));
24
+ }
25
+ const NO_AGENT = "The workspace has no agent deployed, so nobody can deliver the message. " +
26
+ "Deploy the workspace's agent first.";
27
+ /**
28
+ * The built-in agent chat: a group chat the workspace's agent is in. Every ontology includes it,
29
+ * after `User`. The agent records each chat it sees and the members it sees writing there, through
30
+ * `POST /api/ontology/agent/chats/seen`; members say what a chat is for with the generated `update`,
31
+ * which changes only `purpose` and `whenToUse`. `send_message` asks the agent to write to a chat.
32
+ */
33
+ export const AgentChat = entity("agent_chat", {
34
+ id: "ent_agent_chat",
35
+ label: "Agent chat",
36
+ plural: "Agent chats",
37
+ title: "title",
38
+ description: "A group chat the workspace's agent is in. The agent records each chat it sees; " +
39
+ "members say what the chat is for, so that agents know where to write.",
40
+ properties: {
41
+ provider: p.text({ managed: true, description: "The channel, for example telegram." }),
42
+ externalId: p.text({ managed: true, description: "The chat's id at the provider." }),
43
+ title: p.text({
44
+ managed: true,
45
+ description: "The chat's name at the provider, kept current by the agent.",
46
+ }),
47
+ lastMessageAt: p.timestamp({
48
+ optional: true,
49
+ managed: true,
50
+ label: "Last message",
51
+ description: "When the agent last saw a message in the chat, recorded at most every 15 minutes.",
52
+ }),
53
+ purpose: p.text({
54
+ optional: true,
55
+ maxLength: 64,
56
+ description: "A short key that code and agents look the chat up by, such as accounting.",
57
+ }),
58
+ whenToUse: p.text({
59
+ optional: true,
60
+ maxLength: 1000,
61
+ display: "multiline",
62
+ label: "When to use",
63
+ description: "For agents: what the chat is for and when to write there, such as " +
64
+ '"Invoices and payments; Yulia answers on weekdays."',
65
+ }),
66
+ },
67
+ relationships: {
68
+ // By name, so that an ontology rebuilt with the deployed `user` still resolves it.
69
+ members: rel.many("user", {
70
+ inverse: "agentChats",
71
+ phrase: ["includes", "is in"],
72
+ description: "Workspace members the agent has seen writing in the chat.",
73
+ }),
74
+ },
75
+ indexes: [{ properties: ["provider", "externalId"], unique: true }],
76
+ }).actions((self) => ({
77
+ sendMessage: action({
78
+ description: "Ask the workspace's agent to write to a group chat or a person, for example " +
79
+ '"ask Yulia for the amount on invoice 118". message is the request for the agent, written ' +
80
+ "as an instruction to it, not necessarily the text to post. The target is chosen without " +
81
+ "guessing: with chat, that chat (person, if given, is whom the agent addresses there); with " +
82
+ "only person, the one agent chat whose members include them, or, when they are in none, " +
83
+ "their private chat with the agent if they paired a Telegram account. A person in several " +
84
+ "chats is refused with the candidates (id, title, purpose, when to use; only their number " +
85
+ "without agent_chat:read): call again with chat. The agent then writes in that chat and " +
86
+ "handles the replies there, in that chat's own session; this action only delivers the " +
87
+ "request and does not wait for an answer. Requires an agent deployed in the workspace.",
88
+ input: Schema.Struct({
89
+ message: p.text({
90
+ maxLength: 4000,
91
+ description: "The request for the agent, as an instruction: what to ask or tell, of whom.",
92
+ }),
93
+ chat: p.ref("agent_chat", { optional: true, description: "The agent chat to write in." }),
94
+ person: p.ref("user", {
95
+ optional: true,
96
+ description: "The member to address; alone, it picks their chat with the agent.",
97
+ }),
98
+ }),
99
+ hints: { destructive: false },
100
+ connections: [],
101
+ prepare: ({ input, principal }) => Effect.gen(function* () {
102
+ const channel = yield* Effect.serviceOption(AgentChannel);
103
+ if (Option.isNone(channel)) {
104
+ return yield* new ActionRejected({ message: NO_AGENT });
105
+ }
106
+ const person = input.person === undefined ? undefined : yield* User.get(input.person);
107
+ if (person !== undefined && (person.kind !== "member" || !person.active)) {
108
+ return yield* new ActionRejected({
109
+ message: `${person.name} is not an active member, so the agent does not contact them.`,
110
+ });
111
+ }
112
+ let chat;
113
+ let target;
114
+ if (input.chat !== undefined) {
115
+ chat = yield* self.get(input.chat);
116
+ target = yield* groupTarget(chat);
117
+ }
118
+ else if (person !== undefined) {
119
+ const chats = yield* self.find({
120
+ where: { members: { some: { id: { eq: person.id } } } },
121
+ });
122
+ if (chats.length > 1) {
123
+ // The action reads chats whatever the caller holds; only a reader sees which they are.
124
+ if (!isAllowed(permissionOf(self.name, READ_VERB), principal)) {
125
+ return yield* new ActionRejected({
126
+ message: `${person.name} is in ${chats.length} chats with the agent; call again with ` +
127
+ "the chat's id as chat. Listing them requires agent_chat:read.",
128
+ });
129
+ }
130
+ const candidates = chats.map((candidate) => `- ${candidate.id}: ${candidate.title}; purpose: ${candidate.purpose ?? "none"}; ` +
131
+ `when to use: ${candidate.whenToUse ?? "not set"}`);
132
+ return yield* new ActionRejected({
133
+ message: `${person.name} is in ${chats.length} chats with the agent; ` +
134
+ `call again with one of them as chat:\n${candidates.join("\n")}`,
135
+ });
136
+ }
137
+ chat = chats[0];
138
+ if (chat !== undefined) {
139
+ target = yield* groupTarget(chat);
140
+ }
141
+ else {
142
+ const chatId = yield* channel.value.privateChat(person.id);
143
+ if (chatId === undefined) {
144
+ return yield* new ActionRejected({
145
+ message: `${person.name} is in no chat with the agent and has not paired a Telegram ` +
146
+ "account with it, so the agent cannot reach them.",
147
+ });
148
+ }
149
+ target = { kind: "private", provider: TELEGRAM, chatId };
150
+ }
151
+ }
152
+ else {
153
+ return yield* new ActionRejected({
154
+ message: "Name the chat to write in, the person to write to, or both.",
155
+ });
156
+ }
157
+ const requester = yield* User.get(principal.id);
158
+ // The agent acts as a member only through their pairing.
159
+ const actAs = principal.kind === "member" &&
160
+ (yield* channel.value.privateChat(principal.id)) !== undefined;
161
+ yield* channel.value.send({
162
+ target,
163
+ message: input.message,
164
+ person: person === undefined ? null : { id: person.id, name: person.name },
165
+ requester: {
166
+ id: requester.id,
167
+ name: requester.name,
168
+ kind: principal.kind,
169
+ roles: principal.roles,
170
+ actAs,
171
+ },
172
+ });
173
+ return { chat: chat?.id ?? null, target: target.kind, delivered: true };
174
+ }),
175
+ }),
176
+ }));
package/dist/analyze.d.ts CHANGED
@@ -9,7 +9,10 @@ import { type Role } from "./role.js";
9
9
  import { type SignInOptions } from "./sign-in.js";
10
10
  /** The input of `defineOntology` and `check`. */
11
11
  export interface OntologyConfig<Entities extends ReadonlyArray<AnyEntity> = ReadonlyArray<AnyEntity>> {
12
- /** The app's entities. The built-in `User` is always included; listing it is optional. */
12
+ /**
13
+ * The app's entities. The built-in `User` and `AgentChat` are always included; listing them is
14
+ * optional.
15
+ */
13
16
  readonly entities: Entities;
14
17
  /** Business roles. The built-in `member` role is always included. */
15
18
  readonly roles?: ReadonlyArray<Role>;
@@ -82,7 +85,8 @@ export interface ResolvedIndex {
82
85
  }
83
86
  /**
84
87
  * The permissions of an entity's reads and generated actions. The built-in User has no generated
85
- * actions; `link` and `unlink` exist when the entity declares a `rel.many`.
88
+ * actions and the built-in agent chat only `update`; `link` and `unlink` exist when the entity
89
+ * declares a `rel.many`.
86
90
  */
87
91
  export interface EntityPermissions {
88
92
  readonly read: string;
package/dist/analyze.js CHANGED
@@ -7,10 +7,11 @@ import { CONNECTION_KEY, isConnection } from "./connection.js";
7
7
  import { isEntity } from "./entity.js";
8
8
  import { AGGREGATE_ROUTE, LEGACY_MEMBER_NAME, ENTITY_ID, GENERATED_ACTIONS, GENERATED_UPSERT_PREFIX, HISTORY_SUFFIX, MCP_READ_TOOLS, MCP_TOOL_NAME_MAX_LENGTH, MEMBER_ROLE, mcpToolName, RESERVED_ENTITY_NAMES, RESERVED_MEMBER_NAMES, RESERVED_RELATIONSHIP_NAMES, SNAKE_CASE_NAME, SYSTEM_PROPERTIES, isSystemProperty, } from "./names.js";
9
9
  import { REFERENCE_ANNOTATION, propertyDefinition, referenceTarget, } from "./property.js";
10
- import { generatedActionPermissions, parsePermission, permissionOf, READ_VERB, rolesGranting, WILDCARD, } from "./permissions.js";
10
+ import { EXPLICIT_GRANT_ENTITIES, generatedActionPermissions, parsePermission, permissionOf, READ_VERB, rolesGranting, WILDCARD, } from "./permissions.js";
11
11
  import { ON_DELETE, isRelationship } from "./relationship.js";
12
12
  import { isRole, member, role } from "./role.js";
13
13
  import { DEFAULT_SIGN_IN_POLICY, isSignInPolicy } from "./sign-in.js";
14
+ import { AgentChat } from "./agent-chat.js";
14
15
  import { User } from "./user.js";
15
16
  import { authoringToWire } from "./authoring.js";
16
17
  import { nameToWire } from "./authoring-names.js";
@@ -34,10 +35,9 @@ export function analyze(config, options = {}) {
34
35
  diagnostics.push({ severity, code, path, message });
35
36
  };
36
37
  const original = config;
37
- const normalized = authoringToWire(config, options.user ?? User, report);
38
+ const normalized = authoringToWire(config, [options.user ?? User, AgentChat], report);
38
39
  config = normalized.config;
39
- const user = normalized.user;
40
- const entities = collectEntities(config, user, report);
40
+ const entities = collectEntities(config, normalized.builtIns, report);
41
41
  const roles = collectRoles(config, report);
42
42
  const signIn = signInOptions(config, report);
43
43
  const connections = collectConnections(config, report);
@@ -49,7 +49,7 @@ export function analyze(config, options = {}) {
49
49
  }
50
50
  const scope = {
51
51
  report,
52
- user,
52
+ builtIns: new Set(normalized.builtIns.map((candidate) => candidate.definition)),
53
53
  entitiesByName,
54
54
  definitions: new Set(entities.map((candidate) => candidate.definition)),
55
55
  entityProperties: new Set([...entities, ...(original.entities ?? []).filter(isEntity)].flatMap((candidate) => Object.values(candidate.definition.properties ?? {}))),
@@ -74,7 +74,7 @@ export function analyze(config, options = {}) {
74
74
  function toolOperations(candidate) {
75
75
  const { permissions } = candidate;
76
76
  const generated = [
77
- ...(permissions.create !== undefined ? ["create", "update", "delete"] : []),
77
+ ...["create", "update", "delete"].filter((verb) => permissions[verb] !== undefined),
78
78
  ...(permissions.create !== undefined
79
79
  ? candidate.properties
80
80
  .filter(({ definition }) => definition.unique)
@@ -169,9 +169,10 @@ function checkConnectionsUsed(entities, connections, declared, report) {
169
169
  }
170
170
  /**
171
171
  * The keys of an action's connections, each listed in `defineOntology({ connections })`, and the
172
- * phases they require: `prepare` with connections, and `prepare` or `handler` always.
172
+ * phases they require: `prepare` with connections and only with them, except for built-in actions,
173
+ * and `prepare` or `handler` always.
173
174
  */
174
- function resolveActionConnections(path, action, scope) {
175
+ function resolveActionConnections(path, action, scope, builtIn) {
175
176
  const { report } = scope;
176
177
  const declared = action.connections;
177
178
  const keys = [];
@@ -200,7 +201,8 @@ function resolveActionConnections(path, action, scope) {
200
201
  if (Array.isArray(declared) && declared.length > 0 && !hasPrepare) {
201
202
  report("invalid-prepare", `${path}.prepare`, "an action with connections calls them in prepare, which runs before the transaction.");
202
203
  }
203
- if (hasPrepare && Array.isArray(declared) && declared.length === 0) {
204
+ // A built-in action's prepare reaches the platform, such as the workspace's agent, instead.
205
+ if (hasPrepare && !builtIn && Array.isArray(declared) && declared.length === 0) {
204
206
  report("invalid-prepare", `${path}.prepare`, "prepare is for calling connections; an action without connections does its work in handler.");
205
207
  }
206
208
  if (!hasPrepare && !hasHandler) {
@@ -208,8 +210,8 @@ function resolveActionConnections(path, action, scope) {
208
210
  }
209
211
  return keys;
210
212
  }
211
- function collectEntities(config, user, report) {
212
- const entities = [user];
213
+ function collectEntities(config, builtIns, report) {
214
+ const entities = [...builtIns];
213
215
  (config.entities ?? []).forEach((candidate, index) => {
214
216
  if (!isEntity(candidate)) {
215
217
  report("invalid-entity", `entities[${index}]`, "is not an entity; declare entities with entity(name, { … }).");
@@ -330,7 +332,7 @@ function resolveEntity(candidate, scope) {
330
332
  else if (!properties.some((property) => property.name === definition.title)) {
331
333
  report("invalid-title", `${name}.title`, `"${definition.title}" is not a declared property of ${name}.`);
332
334
  }
333
- const builtIn = candidate.definition === scope.user.definition;
335
+ const builtIn = scope.builtIns.has(candidate.definition);
334
336
  return {
335
337
  entity: candidate,
336
338
  name,
@@ -342,11 +344,15 @@ function resolveEntity(candidate, scope) {
342
344
  permissions: entityPermissions(name, builtIn || definition.source !== undefined, relationships),
343
345
  };
344
346
  }
345
- /** Reads need `<entity>:read`; each generated action needs the permission named after it. */
347
+ /**
348
+ * Reads need `<entity>:read`; each generated action needs the permission named after it. Built-in
349
+ * entities hold the platform's records: only `agent_chat` has a generated action, `update`, for the
350
+ * properties members edit.
351
+ */
346
352
  function entityPermissions(name, builtIn, relationships) {
347
353
  const read = permissionOf(name, READ_VERB);
348
354
  if (builtIn) {
349
- return { read };
355
+ return name === AgentChat.name ? { read, update: permissionOf(name, "update") } : { read };
350
356
  }
351
357
  const many = relationships.some(({ relationship }) => relationship.cardinality === "many");
352
358
  return {
@@ -628,7 +634,7 @@ function resolveActions(candidate, scope) {
628
634
  action,
629
635
  permission: resolvePermission(path, permissionOf(candidate.name, name), action, scope),
630
636
  input: resolveInput(path, action.input, scope),
631
- connections: resolveActionConnections(path, action, scope),
637
+ connections: resolveActionConnections(path, action, scope, scope.builtIns.has(candidate.definition)),
632
638
  hints: resolveHints(path, action, report),
633
639
  });
634
640
  }
@@ -762,7 +768,9 @@ function checkEntityIdentities(entities, report) {
762
768
  const existingName = names.get(candidate.name);
763
769
  const existingId = ids.get(definition.id);
764
770
  if (existingName !== undefined) {
765
- const builtIn = existingName.builtIn ? " (the built-in User)" : "";
771
+ const builtIn = existingName.builtIn
772
+ ? ` (the built-in ${existingName.entity.definition.label})`
773
+ : "";
766
774
  report("duplicate-name", candidate.name, `another entity is named ${candidate.name}${builtIn}.`);
767
775
  }
768
776
  else {
@@ -869,7 +877,9 @@ function knownVerbs(entities) {
869
877
  /** Why a role's permission matches nothing in the ontology, or `undefined` when it matches. */
870
878
  function unknownPermission(value, { entity, verb }, verbs) {
871
879
  if (entity === WILDCARD) {
872
- const matched = verb === WILDCARD || [...verbs.values()].some((own) => own.has(verb));
880
+ // A wildcard entity does not reach the entities only a named permission covers.
881
+ const matched = verb === WILDCARD ||
882
+ [...verbs].some(([name, own]) => !EXPLICIT_GRANT_ENTITIES.has(name) && own.has(verb));
873
883
  return matched ? undefined : `${value} matches nothing: no entity has ${verb}.`;
874
884
  }
875
885
  const own = verbs.get(entity);
@@ -961,7 +971,8 @@ function checkPermissions(entities, roles, report) {
961
971
  name: candidate.name,
962
972
  permissions: Array.isArray(candidate.permissions) ? candidate.permissions : [],
963
973
  }));
964
- for (const candidate of entities) {
974
+ // Built-in actions are granted by the workspace's roles when it wants them.
975
+ for (const candidate of entities.filter((entity) => !entity.builtIn)) {
965
976
  for (const { name, permission } of candidate.actions) {
966
977
  const parsed = parsePermission(permission);
967
978
  // An invalid name or permission is already an error; it needs no warning on top.
@@ -4,9 +4,12 @@ import { type AnyEntity } from "./entity.js";
4
4
  import type { Principal } from "./runtime.js";
5
5
  type Report = (code: DiagnosticCode, path: string, message: string) => void;
6
6
  export declare function principalFromWire(principal: Principal): Principal;
7
- /** Canonical definitions feed REST, storage and future transports; the public config stays camelCase. */
8
- export declare function authoringToWire(config: OntologyConfig, user: AnyEntity, report: Report): {
7
+ /**
8
+ * Canonical definitions feed REST, storage and future transports; the public config stays camelCase.
9
+ * The built-in entities, which every ontology includes, are normalized with the declared ones.
10
+ */
11
+ export declare function authoringToWire(config: OntologyConfig, builtIns: ReadonlyArray<AnyEntity>, report: Report): {
9
12
  readonly config: OntologyConfig;
10
- readonly user: AnyEntity;
13
+ readonly builtIns: ReadonlyArray<AnyEntity>;
11
14
  };
12
15
  export {};
package/dist/authoring.js CHANGED
@@ -217,8 +217,11 @@ class AuthoringEncoder {
217
217
  return normalized;
218
218
  }
219
219
  }
220
- /** Canonical definitions feed REST, storage and future transports; the public config stays camelCase. */
221
- export function authoringToWire(config, user, report) {
220
+ /**
221
+ * Canonical definitions feed REST, storage and future transports; the public config stays camelCase.
222
+ * The built-in entities, which every ontology includes, are normalized with the declared ones.
223
+ */
224
+ export function authoringToWire(config, builtIns, report) {
222
225
  const encoder = new AuthoringEncoder(report);
223
226
  const normalized = {
224
227
  ...config,
@@ -233,5 +236,5 @@ export function authoringToWire(config, user, report) {
233
236
  permissions: config.member.permissions.map(permissionToWire),
234
237
  };
235
238
  }
236
- return { config: normalized, user: encoder.entity(user) };
239
+ return { config: normalized, builtIns: builtIns.map((entity) => encoder.entity(entity)) };
237
240
  }
package/dist/index.d.ts CHANGED
@@ -34,6 +34,8 @@ export type { SignInCheck, SignInMethod, SignInOptions, SignInPolicy } from "./s
34
34
  export { OntologyReads, OntologyRuntime, OntologyWrites } from "./runtime.js";
35
35
  export type { FieldCondition, OntologyRuntimeService, FindOptions, GetOptions, Principal, RecordWhere, StoredRecord, StoredValues, UpsertResult, } from "./runtime.js";
36
36
  export { User } from "./user.js";
37
+ export { AgentChannel, AgentChat } from "./agent-chat.js";
38
+ export type { AgentMessage } from "./agent-chat.js";
37
39
  export { DEFAULT_QUERY_LIMIT, entityQueryResultSchema, entityQuerySchema, entityQuerySchemas, entityRecordSchema, FILTER_OPERATORS, MANY_QUANTIFIERS, MAX_IN_VALUES, MAX_QUERY_LIMIT, OrderBy, Query, queryFields, queryJsonSchema, queryLinks, QueryResult, queryResultJsonSchema, queryResultSchema, SortDirection, WHERE_COMBINATORS, } from "./query.js";
38
40
  export type { EntityQuerySchemas, FieldFilter, FilterOperator, JsonRecord, ManyFilter, QueryField, QueryLink, StorageType, Where, } from "./query.js";
39
41
  export { InvalidQuery, InvalidValues, MemberNotFound, PrincipalRejected, StoredRecordMismatch, UnknownAction, UnknownRole, ValidationIssue, WriteOutsideAction, } from "./runtime/errors.js";
@@ -52,10 +54,10 @@ export type { DeriveApiOptions, OntologyApi } from "./service/api.js";
52
54
  export { CurrentPrincipal } from "./service/authentication.js";
53
55
  export { deployedConnections, LocalConnectionContextError, localConnections, OIDC_TOKEN_HEADER, parseLocalConnectionContext, readLocalConnectionContext, RUNTIME_CONTEXT_FD, } from "./service/connections.js";
54
56
  export type { ConnectionAudit, ConnectionTransportFactory, LocalConnection, LocalConnectionContext, } from "./service/connections.js";
55
- export { configFromEnv, ENVIRONMENT_VARIABLES, NEON_AUTH_ENVIRONMENT_VARIABLES, OntologyServiceConfigError, RUNTIME_IDENTITY_ENVIRONMENT_VARIABLES, } from "./service/config.js";
57
+ export { configFromEnv, AGENT_HOST_VARIABLE, ENVIRONMENT_VARIABLES, NEON_AUTH_ENVIRONMENT_VARIABLES, OntologyServiceConfigError, RUNTIME_IDENTITY_ENVIRONMENT_VARIABLES, } from "./service/config.js";
56
58
  export type { ActionCommitted, AfterCommitHook, NeonAuthEnvironment, OntologyEnvironment, OntologyServiceConfig, } from "./service/config.js";
57
59
  export { NotInvited, OntologyBackend } from "./service/runtime-contract.js";
58
- export type { AgentIdentity, AgentMember, AgentPairing, AgentStore, GeneratedActionsProvider, IdentityProvider, OAuthClient, OAuthClientAuthMethod, OAuthGrant, OAuthRequest, OAuthStore, OAuthToken, OAuthTokenKind, OntologyServiceRuntime, RunActionInput, SignInIdentity, UsedOAuthToken, } from "./service/runtime-contract.js";
60
+ export type { AgentIdentity, AgentMember, AgentPairing, AgentStore, SeenChat, GeneratedActionsProvider, IdentityProvider, OAuthClient, OAuthClientAuthMethod, OAuthGrant, OAuthRequest, OAuthStore, OAuthToken, OAuthTokenKind, OntologyServiceRuntime, RunActionInput, SignInIdentity, UsedOAuthToken, } from "./service/runtime-contract.js";
59
61
  export { createOntologyService, ONTOLOGY_HASH_HEADER, REQUEST_ID_HEADER, } from "./service/service.js";
60
62
  export { AGENT_PATH } from "./service/agent/server.js";
61
63
  export { ACT_AS_HEADER } from "./service/runtime-identity.js";
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ export { member, role } from "./role.js";
17
17
  export { DEFAULT_SIGN_IN_POLICY, SIGN_IN_METHODS, SIGN_IN_POLICIES } from "./sign-in.js";
18
18
  export { OntologyReads, OntologyRuntime, OntologyWrites } from "./runtime.js";
19
19
  export { User } from "./user.js";
20
+ export { AgentChannel, AgentChat } from "./agent-chat.js";
20
21
  // The query language of `find` and of the service's list, query and traversal routes.
21
22
  export { DEFAULT_QUERY_LIMIT, entityQueryResultSchema, entityQuerySchema, entityQuerySchemas, entityRecordSchema, FILTER_OPERATORS, MANY_QUANTIFIERS, MAX_IN_VALUES, MAX_QUERY_LIMIT, OrderBy, Query, queryFields, queryJsonSchema, queryLinks, QueryResult, queryResultJsonSchema, queryResultSchema, SortDirection, WHERE_COMBINATORS, } from "./query.js";
22
23
  // The SQL runtime: data access, the action pipeline, generated actions, history and principals.
@@ -31,7 +32,7 @@ export { makeOntologyRuntime } from "./runtime/sql-runtime.js";
31
32
  export { BASE_PATH, deriveApi } from "./service/api.js";
32
33
  export { CurrentPrincipal } from "./service/authentication.js";
33
34
  export { deployedConnections, LocalConnectionContextError, localConnections, OIDC_TOKEN_HEADER, parseLocalConnectionContext, readLocalConnectionContext, RUNTIME_CONTEXT_FD, } from "./service/connections.js";
34
- export { configFromEnv, ENVIRONMENT_VARIABLES, NEON_AUTH_ENVIRONMENT_VARIABLES, OntologyServiceConfigError, RUNTIME_IDENTITY_ENVIRONMENT_VARIABLES, } from "./service/config.js";
35
+ export { configFromEnv, AGENT_HOST_VARIABLE, ENVIRONMENT_VARIABLES, NEON_AUTH_ENVIRONMENT_VARIABLES, OntologyServiceConfigError, RUNTIME_IDENTITY_ENVIRONMENT_VARIABLES, } from "./service/config.js";
35
36
  export { NotInvited, OntologyBackend } from "./service/runtime-contract.js";
36
37
  export { createOntologyService, ONTOLOGY_HASH_HEADER, REQUEST_ID_HEADER, } from "./service/service.js";
37
38
  // The app's own agent: pairing channel accounts with members, and acting as them.
@@ -2,12 +2,18 @@
2
2
  * Permissions are `<entity>:<verb>` strings. The verbs are `read`, the generated `create`, `update`,
3
3
  * `delete`, `link` and `unlink`, and the snake_case name of an authored action. A role may use `*` for
4
4
  * the entity (`*:read`), the verb (`lead:*`) or both (`*:*`); what an action or a read requires never
5
- * holds a wildcard.
5
+ * holds a wildcard. A wildcard entity does not cover the entities in {@link EXPLICIT_GRANT_ENTITIES}.
6
6
  */
7
7
  /** Any entity or any verb, in a role's permissions. */
8
8
  export declare const WILDCARD = "*";
9
9
  /** The verb of every read: lists, queries, records, history, traversals and `include`. */
10
10
  export declare const READ_VERB = "read";
11
+ /**
12
+ * Entities only a permission that names them covers: `*:read` or `*:*` grant nothing on them. The
13
+ * built-in agent chat is in every ontology, and member's default `*:read` would otherwise show anyone
14
+ * who signs in the workspace's chats and who is in them; `agent_chat:read` or `agent_chat:*` does.
15
+ */
16
+ export declare const EXPLICIT_GRANT_ENTITIES: ReadonlySet<string>;
11
17
  /** What the built-in `member` role holds when `defineOntology` does not say: reading everything. */
12
18
  export declare const DEFAULT_MEMBER_PERMISSIONS: ReadonlyArray<string>;
13
19
  export interface ParsedPermission {
@@ -20,7 +26,10 @@ export interface ParsedPermission {
20
26
  export declare function parsePermission(value: unknown): ParsedPermission | undefined;
21
27
  /** `lead:changeStatus`. */
22
28
  export declare function permissionOf(entity: string, verb: string): string;
23
- /** Whether a granted permission, which may hold wildcards, covers the required one. */
29
+ /**
30
+ * Whether a granted permission, which may hold wildcards, covers the required one. A wildcard entity
31
+ * covers every entity but those in {@link EXPLICIT_GRANT_ENTITIES}.
32
+ */
24
33
  export declare function grants(granted: string, required: string): boolean;
25
34
  /** Anything that holds permissions: a principal, or the reader of a catalog. */
26
35
  export interface PermissionHolder {