@segmentstream-cloud/ontology 0.14.0 → 0.16.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 (52) hide show
  1. package/README.md +74 -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/migrate/apply.js +2 -0
  11. package/dist/migrate/plan.js +14 -4
  12. package/dist/permissions.d.ts +11 -2
  13. package/dist/permissions.js +15 -3
  14. package/dist/replication/baseline.js +1 -1
  15. package/dist/replication/model.js +2 -2
  16. package/dist/replication/schema.js +1 -1
  17. package/dist/runtime/agents.d.ts +1 -0
  18. package/dist/runtime/agents.js +160 -0
  19. package/dist/runtime/generated-actions.d.ts +2 -1
  20. package/dist/runtime/generated-actions.js +18 -13
  21. package/dist/runtime/service-binding.js +3 -0
  22. package/dist/service/agent/attachments.d.ts +37 -0
  23. package/dist/service/agent/attachments.js +225 -0
  24. package/dist/service/agent/channel.d.ts +16 -0
  25. package/dist/service/agent/channel.js +88 -0
  26. package/dist/service/agent/protocol.d.ts +14 -0
  27. package/dist/service/agent/protocol.js +26 -0
  28. package/dist/service/agent/server.d.ts +2 -1
  29. package/dist/service/agent/server.js +24 -2
  30. package/dist/service/aggregate-platform.d.ts +5 -0
  31. package/dist/service/aggregate-platform.js +1 -1
  32. package/dist/service/config.d.ts +10 -0
  33. package/dist/service/config.js +8 -0
  34. package/dist/service/handlers.d.ts +2 -1
  35. package/dist/service/handlers.js +2 -2
  36. package/dist/service/mcp/server.d.ts +3 -0
  37. package/dist/service/mcp/server.js +27 -4
  38. package/dist/service/mcp/tools.d.ts +6 -0
  39. package/dist/service/mcp/tools.js +33 -1
  40. package/dist/service/operations.d.ts +6 -2
  41. package/dist/service/operations.js +13 -5
  42. package/dist/service/runtime-contract.d.ts +29 -0
  43. package/dist/service/service.js +18 -2
  44. package/dist/storage/attachments.d.ts +117 -0
  45. package/dist/storage/attachments.js +536 -0
  46. package/dist/storage/maintenance.d.ts +4 -1
  47. package/dist/storage/maintenance.js +7 -2
  48. package/dist/storage/neon.js +14 -0
  49. package/dist/storage/provider.d.ts +2 -0
  50. package/dist/storage/tables.d.ts +8 -0
  51. package/dist/storage/tables.js +32 -0
  52. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { isValueMatched, } from "../manifest.js";
2
- import { BLOB_TABLES_DDL } from "../storage/tables.js";
2
+ import { ATTACHMENTS_TABLE, ATTACHMENT_TABLES_DDL, BLOB_TABLES_DDL } from "../storage/tables.js";
3
3
  import { SNAKE_CASE_NAME, isSystemProperty, toSnakeCase } from "../names.js";
4
4
  import { AGENT_TABLES, AGENT_TABLES_DDL, CATALOG_TABLE_DDL, MAX_IDENTIFIER_LENGTH, OAUTH_TABLES, OAUTH_TABLES_DDL, USER_TABLE, addConstraintStatement, columnDefinition, constraintDefinition, createEnumStatement, createIndexStatement, createTableStatement, defaultExpression, entityStorage, enumTypeName, fieldColumnName, joinTable, propertyCheck, blobForeignKey, propertyColumn, propertyEnum, quoteIdentifier, quoteLiteral, relationshipColumn, RUNTIME_TABLES_DDL, relationshipForeignKey, SYSTEM_COLUMNS, tableName, } from "./storage.js";
5
5
  const GLOBAL_KINDS = new Set(["table", "type", "index"]);
@@ -94,13 +94,23 @@ export function planMigration(input) {
94
94
  }));
95
95
  }
96
96
  }
97
- if (storedTargets.some((entity) => entity.properties.some((property) => property.type === "blob")) &&
97
+ const storesFiles = storedTargets.some((entity) => entity.properties.some((property) => property.type === "blob"));
98
+ if (storesFiles &&
98
99
  !current?.entities.some((entity) => entity.properties.some((property) => property.type === "blob"))) {
99
100
  draft.runtimeTables.push(step({
100
101
  kind: "createCatalog",
101
102
  classification: "additive",
102
- description: "Create the ontology's file revision and upload tables.",
103
- sql: BLOB_TABLES_DDL,
103
+ description: "Create the ontology's file revision, upload and agent attachment tables.",
104
+ sql: [...BLOB_TABLES_DDL, ...ATTACHMENT_TABLES_DDL],
105
+ }));
106
+ }
107
+ else if (storesFiles && input.existingTables?.has(ATTACHMENTS_TABLE) === false) {
108
+ // File storage an earlier SDK created lacks the agent's attachments, added since.
109
+ draft.runtimeTables.push(step({
110
+ kind: "createCatalog",
111
+ classification: "additive",
112
+ description: "Create the agent's temporary attachment table beside the file tables.",
113
+ sql: ATTACHMENT_TABLES_DDL,
104
114
  }));
105
115
  }
106
116
  planDrops({ pairs, removedEntities, addedEntities, allowDrops, draft });
@@ -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 {
@@ -3,12 +3,18 @@ import { GENERATED_UPSERT_PREFIX, toSnakeCase } from "./names.js";
3
3
  * Permissions are `<entity>:<verb>` strings. The verbs are `read`, the generated `create`, `update`,
4
4
  * `delete`, `link` and `unlink`, and the snake_case name of an authored action. A role may use `*` for
5
5
  * the entity (`*:read`), the verb (`lead:*`) or both (`*:*`); what an action or a read requires never
6
- * holds a wildcard.
6
+ * holds a wildcard. A wildcard entity does not cover the entities in {@link EXPLICIT_GRANT_ENTITIES}.
7
7
  */
8
8
  /** Any entity or any verb, in a role's permissions. */
9
9
  export const WILDCARD = "*";
10
10
  /** The verb of every read: lists, queries, records, history, traversals and `include`. */
11
11
  export const READ_VERB = "read";
12
+ /**
13
+ * Entities only a permission that names them covers: `*:read` or `*:*` grant nothing on them. The
14
+ * built-in agent chat is in every ontology, and member's default `*:read` would otherwise show anyone
15
+ * who signs in the workspace's chats and who is in them; `agent_chat:read` or `agent_chat:*` does.
16
+ */
17
+ export const EXPLICIT_GRANT_ENTITIES = new Set(["agent_chat"]);
12
18
  /** What the built-in `member` role holds when `defineOntology` does not say: reading everything. */
13
19
  export const DEFAULT_MEMBER_PERMISSIONS = [`${WILDCARD}:${READ_VERB}`];
14
20
  /** `<entity>:<verb>` with lowercase snake_case entities and lowercase snake_case verbs, or `*` for either. */
@@ -25,14 +31,20 @@ export function parsePermission(value) {
25
31
  export function permissionOf(entity, verb) {
26
32
  return `${entity}:${verb}`;
27
33
  }
28
- /** Whether a granted permission, which may hold wildcards, covers the required one. */
34
+ /**
35
+ * Whether a granted permission, which may hold wildcards, covers the required one. A wildcard entity
36
+ * covers every entity but those in {@link EXPLICIT_GRANT_ENTITIES}.
37
+ */
29
38
  export function grants(granted, required) {
30
39
  const held = parsePermission(granted);
31
40
  const wanted = parsePermission(required);
32
41
  if (held === undefined || wanted === undefined) {
33
42
  return false;
34
43
  }
35
- return ((held.entity === WILDCARD || toSnakeCase(held.entity) === toSnakeCase(wanted.entity)) &&
44
+ const entity = toSnakeCase(wanted.entity);
45
+ return ((held.entity === WILDCARD
46
+ ? !EXPLICIT_GRANT_ENTITIES.has(entity)
47
+ : toSnakeCase(held.entity) === entity) &&
36
48
  (held.verb === WILDCARD || toSnakeCase(held.verb) === toSnakeCase(wanted.verb)));
37
49
  }
38
50
  /**
@@ -23,7 +23,7 @@ export const createReplicationBaseline = (sql, input) => sql.withTransaction(Eff
23
23
  yield* Effect.sync(() => validateReplicationSelection(manifest, input.selection));
24
24
  // Drain older SDK transactions that started before the compatibility fence was enabled.
25
25
  const tables = manifest.entities
26
- .filter((e) => e.source === undefined && e.name !== "user")
26
+ .filter((e) => e.source === undefined && !e.builtIn)
27
27
  .flatMap((e) => [
28
28
  tableName(e.name),
29
29
  ...e.relationships
@@ -69,7 +69,7 @@ export function validateReplicationSelection(manifest, selection) {
69
69
  };
70
70
  for (const item of selection) {
71
71
  const entity = manifest.entities.find((e) => e.id === item.entityId);
72
- if (!entity || entity.name === "user" || entity.source !== undefined) {
72
+ if (!entity || entity.builtIn || entity.source !== undefined) {
73
73
  throw new Error(`Only stored business entities can be replicated: ${item.entityId}.`);
74
74
  }
75
75
  addView(entity.name);
@@ -82,7 +82,7 @@ export function validateReplicationSelection(manifest, selection) {
82
82
  const target = manifest.entities.find((e) => e.name === relationship?.to);
83
83
  if (relationship?.cardinality !== "many" ||
84
84
  !target ||
85
- target.name === "user" ||
85
+ target.builtIn ||
86
86
  !selection.some((s) => s.entityId === target.id)) {
87
87
  throw new Error(`Replication of ${entity.name}.${name} requires both business entities.`);
88
88
  }
@@ -55,7 +55,7 @@ export function snapshotExpression(entity, alias) {
55
55
  /** History is the durable source. Triggers also reject old writers while capture is enabled. */
56
56
  export function replicationEntityDdl(manifest) {
57
57
  return manifest.entities
58
- .filter((e) => e.source === undefined && e.name !== "user")
58
+ .filter((e) => e.source === undefined && !e.builtIn)
59
59
  .flatMap((entity) => {
60
60
  const history = qi(historyTableName(entity.name));
61
61
  const table = qi(tableName(entity.name));
@@ -5,5 +5,6 @@ import type { AgentStore } from "../service/runtime-contract.js";
5
5
  * The agent store on the runtime's agent tables, as `ontology_app`. Expiry is the database's
6
6
  * `now()`; a pairing is consumed with one `UPDATE … WHERE "consumed_at" IS NULL`, so two concurrent
7
7
  * approvals cannot both succeed. Ids that are not UUIDs find nothing rather than fail the cast.
8
+ * Chats the agent sees are `agent_chat` records, written as the platform with history.
8
9
  */
9
10
  export declare function sqlAgentStore(ontology: Ontology, sql: SqlClient.SqlClient): AgentStore;
@@ -1,8 +1,13 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { Effect } from "effect";
2
3
  import { SqlClient } from "effect/unstable/sql";
4
+ import { AgentChat } from "../agent-chat.js";
3
5
  import { AGENT_IDENTITIES_TABLE, AGENT_PAIRINGS_TABLE, USER_TABLE } from "../migrate/storage.js";
6
+ import { MAX_QUERY_LIMIT } from "../query.js";
7
+ import { beginReplicationWrite } from "../replication/transaction.js";
4
8
  import { resolveMemberById } from "./principals.js";
5
9
  import { isRecordId } from "./records.js";
10
+ import { makeOntologyRuntime } from "./sql-runtime.js";
6
11
  const PAIRINGS = `"${AGENT_PAIRINGS_TABLE}"`;
7
12
  const IDENTITIES = `"${AGENT_IDENTITIES_TABLE}"`;
8
13
  const USERS = `"${USER_TABLE}"`;
@@ -14,6 +19,16 @@ const PAIRING_COLUMNS = `"id"::text AS "id", "provider", "external_id", "display
14
19
  `${epochMs(`"expires_at"`)} AS "expires_at"`;
15
20
  const IDENTITY_COLUMNS = `"id"::text AS "id", "provider", "external_id", "user_id"::text AS "user_id", "display", ` +
16
21
  `${epochMs(`"created_at"`)} AS "created_at", ${epochMs(`"revoked_at"`)} AS "revoked_at"`;
22
+ /**
23
+ * How often a chat's `last_message_at` is recorded: each record is a history row, and a busy chat
24
+ * would otherwise add one per message.
25
+ */
26
+ const CHAT_MESSAGE_RECORD_INTERVAL_MS = 15 * 60 * 1000;
27
+ /** Whether a chat's latest message is recorded: it has none yet, or none this interval. */
28
+ function messageDue(lastMessageAt, now) {
29
+ const last = typeof lastMessageAt === "string" ? Date.parse(lastMessageAt) : Number.NaN;
30
+ return Number.isNaN(last) || now.getTime() - last >= CHAT_MESSAGE_RECORD_INTERVAL_MS;
31
+ }
17
32
  function nullableText(value) {
18
33
  return value === null || value === undefined ? null : String(value);
19
34
  }
@@ -41,6 +56,7 @@ function identityOf(row) {
41
56
  * The agent store on the runtime's agent tables, as `ontology_app`. Expiry is the database's
42
57
  * `now()`; a pairing is consumed with one `UPDATE … WHERE "consumed_at" IS NULL`, so two concurrent
43
58
  * approvals cannot both succeed. Ids that are not UUIDs find nothing rather than fail the cast.
59
+ * Chats the agent sees are `agent_chat` records, written as the platform with history.
44
60
  */
45
61
  export function sqlAgentStore(ontology, sql) {
46
62
  const rows = (statement, values = []) => sql.unsafe(statement, values).pipe(Effect.orDie);
@@ -141,5 +157,149 @@ export function sqlAgentStore(ontology, sql) {
141
157
  const [paired] = yield* rows(`SELECT 1 FROM ${IDENTITIES} WHERE "user_id" = $1::uuid AND "revoked_at" IS NULL LIMIT 1`, [userId]);
142
158
  return paired === undefined ? undefined : yield* memberPrincipal(userId);
143
159
  }),
160
+ seeChat: ({ provider, externalId, title, memberId, leftMemberId, agentLeft, migratedFrom }) => sql
161
+ .withTransaction(Effect.gen(function* () {
162
+ yield* beginReplicationWrite(sql);
163
+ const previous = migratedFrom === undefined || migratedFrom === externalId ? undefined : migratedFrom;
164
+ // Calls for one chat run one after the other: the first creates it, the rest find it. A
165
+ // migration locks both chats, in one order, so that two calls never wait on each other.
166
+ for (const id of [externalId, ...(previous === undefined ? [] : [previous])].sort()) {
167
+ yield* rows(`SELECT pg_advisory_xact_lock(hashtext($1))`, [`chat:${provider}:${id}`]);
168
+ }
169
+ const runtime = makeOntologyRuntime(ontology, sql, {
170
+ action: `${AgentChat.name}.seen`,
171
+ actionId: randomUUID(),
172
+ principalId: null,
173
+ });
174
+ const now = new Date();
175
+ const chatOf = (id) => runtime
176
+ .find({
177
+ entity: AgentChat.name,
178
+ options: { where: { provider, external_id: id }, limit: 1, forUpdate: true },
179
+ })
180
+ .pipe(Effect.map(([chat]) => chat));
181
+ const membersOf = (id) => runtime
182
+ .related({
183
+ entity: AgentChat.name,
184
+ id,
185
+ relationship: "members",
186
+ query: { limit: MAX_QUERY_LIMIT },
187
+ })
188
+ .pipe(Effect.map((page) => page.items.map((member) => String(member.id))));
189
+ // Unlinks every member of the chat, linking each to `to` first when given. Each page
190
+ // unlinked is gone, so the first page is read until it is empty.
191
+ const releaseMembers = (id, to) => Effect.gen(function* () {
192
+ for (;;) {
193
+ const members = yield* membersOf(id);
194
+ if (members.length === 0) {
195
+ return;
196
+ }
197
+ for (const targetId of members) {
198
+ if (to !== undefined) {
199
+ yield* runtime.link({
200
+ entity: AgentChat.name,
201
+ relationship: "members",
202
+ id: to,
203
+ targetId,
204
+ });
205
+ }
206
+ yield* runtime.unlink({
207
+ entity: AgentChat.name,
208
+ relationship: "members",
209
+ id,
210
+ targetId,
211
+ });
212
+ }
213
+ }
214
+ });
215
+ let found = yield* chatOf(externalId);
216
+ if (previous !== undefined) {
217
+ const old = yield* chatOf(previous);
218
+ if (old !== undefined && found === undefined) {
219
+ // The group became a supergroup: the chat keeps its record under its new id.
220
+ found = yield* runtime.update({
221
+ entity: AgentChat.name,
222
+ id: String(old.id),
223
+ values: { external_id: externalId },
224
+ });
225
+ }
226
+ else if (old !== undefined && found !== undefined) {
227
+ // Both were recorded: the new one keeps what members said and gains the members,
228
+ // and the old one is deleted.
229
+ const kept = {};
230
+ for (const key of ["purpose", "when_to_use"]) {
231
+ if (nullableText(found[key]) === null && nullableText(old[key]) !== null) {
232
+ kept[key] = old[key];
233
+ }
234
+ }
235
+ if (Object.keys(kept).length > 0) {
236
+ found = yield* runtime.update({
237
+ entity: AgentChat.name,
238
+ id: String(found.id),
239
+ values: kept,
240
+ });
241
+ }
242
+ yield* releaseMembers(String(old.id), String(found.id));
243
+ yield* runtime.delete({ entity: AgentChat.name, id: String(old.id) });
244
+ }
245
+ }
246
+ const changes = {
247
+ ...(found?.title === title ? {} : { title }),
248
+ ...(messageDue(found?.last_message_at, now)
249
+ ? { last_message_at: now.toISOString() }
250
+ : {}),
251
+ };
252
+ const chat = found === undefined
253
+ ? yield* runtime.create({
254
+ entity: AgentChat.name,
255
+ values: { provider, external_id: externalId, ...changes },
256
+ })
257
+ : Object.keys(changes).length === 0
258
+ ? found
259
+ : yield* runtime.update({
260
+ entity: AgentChat.name,
261
+ id: String(found.id),
262
+ values: changes,
263
+ });
264
+ if (memberId !== undefined && isRecordId(memberId)) {
265
+ const [member] = yield* rows(`SELECT 1 FROM ${USERS} WHERE "id" = $1::uuid AND "kind"::text = 'member' AND "active"`, [memberId]);
266
+ // A link already there changes nothing and records no history.
267
+ if (member !== undefined) {
268
+ yield* runtime.link({
269
+ entity: AgentChat.name,
270
+ relationship: "members",
271
+ id: String(chat.id),
272
+ targetId: memberId,
273
+ });
274
+ }
275
+ }
276
+ if (leftMemberId !== undefined && isRecordId(leftMemberId)) {
277
+ const [user] = yield* rows(`SELECT 1 FROM ${USERS} WHERE "id" = $1::uuid`, [
278
+ leftMemberId,
279
+ ]);
280
+ // Whatever their state now; a member who is not linked changes nothing and records no
281
+ // history.
282
+ if (user !== undefined) {
283
+ yield* runtime.unlink({
284
+ entity: AgentChat.name,
285
+ relationship: "members",
286
+ id: String(chat.id),
287
+ targetId: leftMemberId,
288
+ });
289
+ }
290
+ }
291
+ if (agentLeft === true) {
292
+ // The agent left: no member is in the chat with it any more. The chat stays; members
293
+ // link again when they write after the agent is back.
294
+ yield* releaseMembers(String(chat.id));
295
+ }
296
+ return {
297
+ id: String(chat.id),
298
+ title: String(chat.title),
299
+ purpose: nullableText(chat.purpose),
300
+ whenToUse: nullableText(chat.when_to_use),
301
+ };
302
+ }))
303
+ .pipe(Effect.orDie),
144
304
  };
145
305
  }
@@ -12,7 +12,8 @@ export declare function upsertActionName(property: string): string;
12
12
  * are ordinary actions and run through the same pipeline. Their `permission` is unset: each requires
13
13
  * the permission named after it (`lead:create`), and `upsert_by_<property>` both `create` and `update`,
14
14
  * as `generatedActionPermissions` says. The built-in User has none: members are created by sign-in
15
- * and services by the app or the CLI.
15
+ * and services by the app or the CLI. The built-in agent chat has only `update`, for its unmanaged
16
+ * properties: the agent records chats and their members.
16
17
  */
17
18
  export declare function generatedActions(ontology: Ontology, entityName: string): GeneratedActions;
18
19
  /** An action's input as JSON Schema (draft 2020-12), as the manifest describes authored actions. */
@@ -64,7 +64,7 @@ function valueFields(entity, { mode }) {
64
64
  ? target
65
65
  : Schema.optionalKey(link.optional ? Schema.NullOr(target) : target);
66
66
  }
67
- else if (link.via === "join" && !link.inverse) {
67
+ else if (link.via === "join" && !link.inverse && !entity.resolved.builtIn) {
68
68
  fields[link.name] = Schema.optionalKey(Schema.Array(reference(link, `The id of a ${link.target} record.`)).annotate({
69
69
  description: `${relationshipDescription(entity, link)} The list replaces the current links.`,
70
70
  }));
@@ -87,7 +87,8 @@ function valuesOf(input, omit) {
87
87
  * are ordinary actions and run through the same pipeline. Their `permission` is unset: each requires
88
88
  * the permission named after it (`lead:create`), and `upsert_by_<property>` both `create` and `update`,
89
89
  * as `generatedActionPermissions` says. The built-in User has none: members are created by sign-in
90
- * and services by the app or the CLI.
90
+ * and services by the app or the CLI. The built-in agent chat has only `update`, for its unmanaged
91
+ * properties: the agent records chats and their members.
91
92
  */
92
93
  export function generatedActions(ontology, entityName) {
93
94
  if (ontology.manifest.entities.find((e) => e.name === entityName)?.source !== undefined) {
@@ -101,7 +102,7 @@ export function generatedActions(ontology, entityName) {
101
102
  }
102
103
  function buildGeneratedActions(ontology, entityName) {
103
104
  const entity = entityModel(ontologyModel(ontology), entityName);
104
- if (entity.resolved.builtIn) {
105
+ if (entity.resolved.builtIn && entity.resolved.permissions.update === undefined) {
105
106
  return {};
106
107
  }
107
108
  const label = entity.label.toLowerCase();
@@ -109,22 +110,26 @@ function buildGeneratedActions(ontology, entityName) {
109
110
  const managed = entity.fields
110
111
  .filter((field) => field.property?.definition.managed === true)
111
112
  .map((field) => field.name);
113
+ const update = action({
114
+ description: `Change ${article(label)}'s properties and relationships; omitted values stay as they are.` +
115
+ (managed.length > 0 ? ` ${managed.join(", ")} change only through other actions.` : ""),
116
+ input: Schema.Struct({ id: id("change"), ...valueFields(entity, { mode: "update" }) }),
117
+ handler: ({ input }) => Effect.flatMap(OntologyWrites, (runtime) => runtime.update({
118
+ entity: entityName,
119
+ id: input.id,
120
+ values: valuesOf(input, ["id"]),
121
+ })),
122
+ });
123
+ if (entity.resolved.builtIn) {
124
+ return { update };
125
+ }
112
126
  const actions = {
113
127
  create: action({
114
128
  description: `Create ${article(label)}.`,
115
129
  input: Schema.Struct(valueFields(entity, { mode: "create" })),
116
130
  handler: ({ input }) => Effect.flatMap(OntologyWrites, (runtime) => runtime.create({ entity: entityName, values: input })),
117
131
  }),
118
- update: action({
119
- description: `Change ${article(label)}'s properties and relationships; omitted values stay as they are.` +
120
- (managed.length > 0 ? ` ${managed.join(", ")} change only through other actions.` : ""),
121
- input: Schema.Struct({ id: id("change"), ...valueFields(entity, { mode: "update" }) }),
122
- handler: ({ input }) => Effect.flatMap(OntologyWrites, (runtime) => runtime.update({
123
- entity: entityName,
124
- id: input.id,
125
- values: valuesOf(input, ["id"]),
126
- })),
127
- }),
132
+ update,
128
133
  delete: action({
129
134
  description: `Delete ${article(label)}. Records that refer to it are deleted, cleared or protect it, ` +
130
135
  "as each relationship's onDelete says.",
@@ -3,6 +3,7 @@ import { Effect, Layer, Redacted } from "effect";
3
3
  import { SqlClient } from "effect/unstable/sql";
4
4
  import { neonBlobProvider } from "../storage/neon.js";
5
5
  import { maintainBlobStorage } from "../storage/maintenance.js";
6
+ import { attachmentRuntime } from "../storage/attachments.js";
6
7
  import { blobRuntime } from "../storage/sessions.js";
7
8
  import { readCatalog } from "../migrate/apply.js";
8
9
  import { NotInvited, OntologyBackend, } from "../service/runtime-contract.js";
@@ -33,8 +34,10 @@ export function backendLayer(ontology, blobStorage) {
33
34
  ? yield* Effect.acquireRelease(Effect.sync(() => neonBlobProvider(binding)), (provider) => Effect.sync(() => provider.close?.()))
34
35
  : undefined;
35
36
  const blobs = provider ? blobRuntime(ontology, sql, provider) : undefined;
37
+ const attachments = provider ? attachmentRuntime(ontology, sql, provider) : undefined;
36
38
  return OntologyBackend.of({
37
39
  ...(blobs ? { blobs } : {}),
40
+ ...(attachments ? { attachments } : {}),
38
41
  ...(provider ? { maintainBlobs: maintainBlobStorage(sql, provider) } : {}),
39
42
  runAction: (input) => withSql(runAction(ontology, {
40
43
  entity: input.entity,
@@ -0,0 +1,37 @@
1
+ import { Layer } from "effect";
2
+ import { HttpRouter } from "effect/unstable/http";
3
+ import { type TokenVerifier } from "../authentication.js";
4
+ import type { AfterCommitHook } from "../config.js";
5
+ import type { ConnectionTransportFactory } from "../connections.js";
6
+ import { RequestId } from "../errors.js";
7
+ import type { ApiPlan } from "../plan.js";
8
+ import type { ReplicationKick } from "../replication.js";
9
+ import { OntologyBackend } from "../runtime-contract.js";
10
+ import { type RuntimeTokenVerifier } from "../runtime-identity.js";
11
+ import type { AgentChannelFactory } from "./channel.js";
12
+ /**
13
+ * The agent's temporary attachments over HTTP (`docs/agent.md`, "Attachments"). Only the
14
+ * deployment's runtime token reaches these routes, acting as the member in `X-SegmentStream-Act-As`
15
+ * while they are active and paired. The chat and the agent session come from the agent's runtime in
16
+ * `X-SegmentStream-Agent-Conversation` and `X-SegmentStream-Agent-Session`, never from the model's
17
+ * tool arguments; every operation is confined to that member, chat and session.
18
+ */
19
+ export declare const ATTACHMENTS_PATH = "/api/ontology/agent/attachments";
20
+ /** The chat an attachment belongs to, such as `telegram:12345`. */
21
+ export declare const CONVERSATION_HEADER = "x-segmentstream-agent-conversation";
22
+ /** The agent session the request comes from. */
23
+ export declare const SESSION_HEADER = "x-segmentstream-agent-session";
24
+ export interface AttachmentRoutesOptions {
25
+ readonly plan: ApiPlan;
26
+ readonly hooks: ReadonlyMap<string, AfterCommitHook>;
27
+ readonly connections: ConnectionTransportFactory;
28
+ readonly replicate: ReplicationKick;
29
+ readonly agentChannel?: AgentChannelFactory | undefined;
30
+ readonly verifyToken: TokenVerifier;
31
+ readonly verifyRuntime: RuntimeTokenVerifier | undefined;
32
+ }
33
+ /**
34
+ * The attachment routes. Without file storage or the agent tables, or while runtime authentication
35
+ * is disabled, it serves nothing.
36
+ */
37
+ export declare function attachmentsLayer(options: AttachmentRoutesOptions): Layer.Layer<never, never, OntologyBackend | HttpRouter.HttpRouter | HttpRouter.Request<"Requires", RequestId>>;