@segmentstream-cloud/ontology 0.20.0 → 0.22.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.
package/README.md CHANGED
@@ -370,7 +370,7 @@ both are recorded, moves the members and a missing `purpose` and `whenToUse` to
370
370
  deletes the old one. `provider`, `externalId`, `title` and `lastMessageAt` are
371
371
  managed. Members set `purpose` and `whenToUse` with the generated
372
372
  `agent_chat.update`, the chat's only generated action. The built-in action
373
- `agent_chat.send_message` (`{ message, chat?, person? }`, `message` at most 4000 characters) asks the agent to write to the chat
373
+ `agent_chat.send_message` (`{ message, chat?, person?, document_ids? }`, `message` at most 4000 characters) asks the agent to write to the chat
374
374
  given, else to the person's one chat, else to their paired private chat; a person in several
375
375
  chats is refused with the candidates, or only their number without `agent_chat:read`. The service posts the message to the agent at
376
376
  `https://<VERCEL_PROJECT_PRODUCTION_URL>/eve/v1/telegram/messages` with the deployment's OIDC
@@ -382,6 +382,30 @@ chats needs `agent_chat:read` by name: `*:read` and `*:*` do not cover `agent_ch
382
382
 
383
383
  ### Agent attachments
384
384
 
385
+ Ontology SDK 0.21.0 adds `documentIds` to the authored action input (`document_ids` on REST/MCP):
386
+ an optional array of at most ten `document` record UUIDs. Before posting anything to the agent,
387
+ the action checks `document:read`, every record's existence and its single file property.
388
+ Missing files refuse the request; unreadable files require the read permission; ambiguous file
389
+ properties are refused. Requests without the field retain their previous behavior. The service
390
+ requires a paired member requester for requests with documents, since the agent downloads as
391
+ that member; services and unpaired requesters may still send requests without documents. It
392
+ forwards `documentIds` to `POST /eve/v1/telegram/messages`; agent SDK 0.5.0 includes the ids in
393
+ `<agent_request>` and sends the stored bytes with `segmentstream__attachments_send`.
394
+
395
+ `GET /api/ontology/agent/files/:entity/:id[/:property]` returns `{ file, download }` for the
396
+ current file property of an ontology record. Like temporary attachment routes, it requires a
397
+ production runtime bearer token, `X-SegmentStream-Act-As` naming an active paired member,
398
+ and the agent conversation/session headers. It checks `<entity>:read` before returning metadata
399
+ and rechecks live permissions when granting a download pinned to that revision. The response is
400
+ uncached; the download grant is short lived. Without a property the entity must have exactly one
401
+ file property. Missing records/files return `not_found`, missing read access `forbidden`, and
402
+ non-file or ambiguous properties `unsupported`.
403
+
404
+ Outbound Telegram uploads currently have an effective **2 MiB multipart request limit** in the
405
+ connection proxy, including overhead, even though Telegram accepts larger files. The agent splits
406
+ compatible albums to fit and returns per-file errors. Group purpose and document restrictions
407
+ still apply; a member's read access alone does not authorize sharing a restricted document in a group.
408
+
385
409
  A file a member sends the agent is a temporary attachment: platform metadata in the runtime
386
410
  table `_agent_attachments`, never an ontology record, with its bytes stored once as a revision in
387
411
  the ontology's file storage, beside uploads. It belongs to the member, the chat it arrived in and
@@ -14,6 +14,8 @@ export interface AgentMessage {
14
14
  };
15
15
  /** The request for the agent, written as an instruction to it. */
16
16
  readonly message: string;
17
+ /** Document record ids to send with the request; absent from older requests. */
18
+ readonly documentIds?: ReadonlyArray<string>;
17
19
  /** Whom the agent should address, when the caller named someone. */
18
20
  readonly person: {
19
21
  readonly id: string;
@@ -63,6 +65,7 @@ export declare const AgentChat: import("./entity.js").Entity<"agent_chat", {
63
65
  }, {
64
66
  readonly sendMessage: import("./action.js").Action<Schema.Struct<{
65
67
  readonly message: import("./property.js").Property<string, false, false>;
68
+ readonly documentIds: Schema.optionalKey<Schema.$Array<Schema.String>>;
66
69
  readonly chat: Schema.optionalKey<Schema.Codec<string, string, never, never>>;
67
70
  readonly person: Schema.optionalKey<Schema.Codec<string, string, never, never>>;
68
71
  }>, {
@@ -6,6 +6,7 @@ import { isAllowed, permissionOf, READ_VERB } from "./permissions.js";
6
6
  import { p } from "./property.js";
7
7
  import { rel } from "./relationship.js";
8
8
  import { User } from "./user.js";
9
+ import { AgentFileRecords } from "./runtime/files.js";
9
10
  /** The only channel the agent delivers to so far, and the provider of its chats. */
10
11
  export const TELEGRAM = "telegram";
11
12
  /**
@@ -84,12 +85,16 @@ export const AgentChat = entity("agent_chat", {
84
85
  "chats is refused with the candidates (id, title, purpose, when to use; only their number " +
85
86
  "without agent_chat:read): call again with chat. The agent then writes in that chat and " +
86
87
  "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
+ "request and does not wait for an answer. document_ids optionally names up to ten readable " +
89
+ "document records to attach with the question. Requires an agent deployed in the workspace.",
88
90
  input: Schema.Struct({
89
91
  message: p.text({
90
92
  maxLength: 4000,
91
93
  description: "The request for the agent, as an instruction: what to ask or tell, of whom.",
92
94
  }),
95
+ documentIds: Schema.optionalKey(Schema.Array(Schema.String.check(Schema.isUUID()))
96
+ .check(Schema.isMaxLength(10))
97
+ .annotate({ description: "Up to ten document record ids the requester may read." })),
93
98
  chat: p.ref("agent_chat", { optional: true, description: "The agent chat to write in." }),
94
99
  person: p.ref("user", {
95
100
  optional: true,
@@ -103,6 +108,19 @@ export const AgentChat = entity("agent_chat", {
103
108
  if (Option.isNone(channel)) {
104
109
  return yield* new ActionRejected({ message: NO_AGENT });
105
110
  }
111
+ if (input.documentIds !== undefined && input.documentIds.length > 0) {
112
+ const files = yield* Effect.serviceOption(AgentFileRecords);
113
+ if (Option.isNone(files)) {
114
+ return yield* new ActionRejected({
115
+ message: "This runtime cannot validate stored documents.",
116
+ });
117
+ }
118
+ for (const id of input.documentIds) {
119
+ yield* files.value
120
+ .resolve({ entity: "document", id }, principal)
121
+ .pipe(Effect.catchTag("UnsupportedFile", (error) => Effect.fail(new ActionRejected({ message: error.message }))));
122
+ }
123
+ }
106
124
  const person = input.person === undefined ? undefined : yield* User.get(input.person);
107
125
  if (person !== undefined && (person.kind !== "member" || !person.active)) {
108
126
  return yield* new ActionRejected({
@@ -158,9 +176,15 @@ export const AgentChat = entity("agent_chat", {
158
176
  // The agent acts as a member only through their pairing.
159
177
  const actAs = principal.kind === "member" &&
160
178
  (yield* channel.value.privateChat(principal.id)) !== undefined;
179
+ if ((input.documentIds?.length ?? 0) > 0 && !actAs) {
180
+ return yield* new ActionRejected({
181
+ message: "Sending documents requires a member requester with a paired Telegram account.",
182
+ });
183
+ }
161
184
  yield* channel.value.send({
162
185
  target,
163
186
  message: input.message,
187
+ ...(input.documentIds === undefined ? {} : { documentIds: input.documentIds }),
164
188
  person: person === undefined ? null : { id: person.id, name: person.name },
165
189
  requester: {
166
190
  id: requester.id,
@@ -1,13 +1,12 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { Duration, Effect, Schema } from "effect";
3
2
  import { canonicalJson, decodeManifestDocument, encodeManifest, manifestHash, } from "../manifest.js";
4
3
  import { CatalogInvalid, CatalogMismatch, MigrationFailed, MigrationRefused } from "./errors.js";
5
4
  import { isNoopMigration } from "./model.js";
6
5
  import { planMigration } from "./plan.js";
7
6
  import { REPLICATION_DDL, REPLICATION_SCHEMA_VERSION, replicationEntityDdl, } from "../replication/schema.js";
8
- import { createReplicationBaseline } from "../replication/baseline.js";
7
+ import { replicationMigrationRefusals } from "../replication/migrate.js";
9
8
  import { ATTACHMENTS_TABLE } from "../storage/tables.js";
10
- import { resolveReplicationSelection } from "../replication/model.js";
9
+ import { resolveReplicationSelection, } from "../replication/model.js";
11
10
  import { AGENT_TABLES, API_KEYS_TABLE, CATALOG_TABLE, OAUTH_TABLES, USER_ROLES_TABLE, quoteIdentifier, quoteLiteral, } from "./storage.js";
12
11
  const CATALOG = quoteIdentifier(CATALOG_TABLE);
13
12
  /** Serializes migrations of one database; transaction-scoped, so it ends with the transaction. */
@@ -122,8 +121,25 @@ export const applyMigration = (plan, options) => Effect.gen(function* () {
122
121
  if (presence?.present) {
123
122
  const [state] = yield* sql.unsafe("SELECT * FROM _ontology_replication WHERE id FOR UPDATE");
124
123
  if (state?.enabled) {
125
- refresh = state.exclusions ?? [];
126
- // Writers wait on this row. Capture, schema changes and the new baseline commit together.
124
+ refresh = yield* Effect.try({
125
+ try: () => resolveReplicationSelection(plan.target, state.exclusions ?? []),
126
+ catch: (cause) => new CatalogInvalid({ message: "The replication exclusions are invalid.", cause }),
127
+ });
128
+ const [target] = yield* sql.unsafe("SELECT * FROM _ontology_replication_targets WHERE generation = $1::uuid", [state.active_generation]);
129
+ if (!target) {
130
+ return yield* new CatalogInvalid({
131
+ message: "The active replication target is missing; pause and rebuild replication.",
132
+ cause: { generation: state.active_generation },
133
+ });
134
+ }
135
+ const refusals = replicationMigrationRefusals(target, plan.target, refresh);
136
+ if (refusals.length > 0) {
137
+ return yield* new MigrationRefused({
138
+ message: refusals.map((refusal) => `${refusal.path}: ${refusal.message}`).join(" "),
139
+ refusals,
140
+ });
141
+ }
142
+ // Writers wait on this row. Capture and schema changes commit together on the same stream.
127
143
  yield* sql.unsafe("UPDATE _ontology_replication SET enabled = false WHERE id");
128
144
  }
129
145
  }
@@ -148,14 +164,9 @@ export const applyMigration = (plan, options) => Effect.gen(function* () {
148
164
  "applied_at" = now()
149
165
  `, [plan.target.version, plan.target.hash, canonicalJson(encodeManifest(plan.target))]);
150
166
  if (refresh !== undefined) {
151
- const selection = yield* Effect.sync(() => resolveReplicationSelection(plan.target, refresh));
152
- yield* createReplicationBaseline(sql, {
153
- generation: randomUUID(),
154
- manifest: plan.target,
155
- exclusions: refresh,
156
- selection,
157
- newSource: true,
158
- });
167
+ yield* sql.unsafe(`UPDATE _ontology_replication_targets SET manifest = $1::jsonb, selection = $2::jsonb
168
+ WHERE generation = (SELECT active_generation FROM _ontology_replication WHERE id)`, [JSON.stringify(plan.target), JSON.stringify(refresh)]);
169
+ yield* sql.unsafe("UPDATE _ontology_replication SET selection = $1::jsonb, enabled = true WHERE id", [JSON.stringify(Object.fromEntries(refresh.map((item) => [item.entityId, item])))]);
159
170
  }
160
171
  }));
161
172
  return { hash: plan.target.hash, appliedSteps: plan.steps.length, changed: true };
@@ -2,7 +2,7 @@ import { Schema } from "effect";
2
2
  declare const MigrationRefused_base: Schema.Class<MigrationRefused, Schema.TaggedStruct<"MigrationRefused", {
3
3
  readonly message: Schema.String;
4
4
  readonly refusals: Schema.$Array<Schema.Struct<{
5
- readonly code: Schema.Literals<readonly ["invalid-manifest", "type-change", "relationship-change", "drop-refused", "name-collision", "identifier-too-long", "rename-conflict"]>;
5
+ readonly code: Schema.Literals<readonly ["invalid-manifest", "type-change", "relationship-change", "drop-refused", "name-collision", "identifier-too-long", "rename-conflict", "replication-rebuild-required"]>;
6
6
  readonly path: Schema.String;
7
7
  readonly message: Schema.String;
8
8
  }>>;
@@ -25,11 +25,11 @@ export declare const MigrationStep: Schema.Struct<{
25
25
  readonly sql: Schema.$Array<Schema.String>;
26
26
  }>;
27
27
  export type MigrationStep = typeof MigrationStep.Type;
28
- export declare const MIGRATION_REFUSAL_CODES: readonly ["invalid-manifest", "type-change", "relationship-change", "drop-refused", "name-collision", "identifier-too-long", "rename-conflict"];
28
+ export declare const MIGRATION_REFUSAL_CODES: readonly ["invalid-manifest", "type-change", "relationship-change", "drop-refused", "name-collision", "identifier-too-long", "rename-conflict", "replication-rebuild-required"];
29
29
  export type MigrationRefusalCode = (typeof MIGRATION_REFUSAL_CODES)[number];
30
30
  /** A change the planner will not make. A plan with refusals cannot be applied. */
31
31
  export declare const MigrationRefusal: Schema.Struct<{
32
- readonly code: Schema.Literals<readonly ["invalid-manifest", "type-change", "relationship-change", "drop-refused", "name-collision", "identifier-too-long", "rename-conflict"]>;
32
+ readonly code: Schema.Literals<readonly ["invalid-manifest", "type-change", "relationship-change", "drop-refused", "name-collision", "identifier-too-long", "rename-conflict", "replication-rebuild-required"]>;
33
33
  /** Where, in the style of `check` diagnostics: `lead.properties.status`. */
34
34
  readonly path: Schema.String;
35
35
  readonly message: Schema.String;
@@ -284,7 +284,7 @@ export declare const MigrationPlan: Schema.Struct<{
284
284
  readonly sql: Schema.$Array<Schema.String>;
285
285
  }>>;
286
286
  readonly refusals: Schema.$Array<Schema.Struct<{
287
- readonly code: Schema.Literals<readonly ["invalid-manifest", "type-change", "relationship-change", "drop-refused", "name-collision", "identifier-too-long", "rename-conflict"]>;
287
+ readonly code: Schema.Literals<readonly ["invalid-manifest", "type-change", "relationship-change", "drop-refused", "name-collision", "identifier-too-long", "rename-conflict", "replication-rebuild-required"]>;
288
288
  /** Where, in the style of `check` diagnostics: `lead.properties.status`. */
289
289
  readonly path: Schema.String;
290
290
  readonly message: Schema.String;
@@ -55,6 +55,7 @@ export const MIGRATION_REFUSAL_CODES = [
55
55
  "name-collision",
56
56
  "identifier-too-long",
57
57
  "rename-conflict",
58
+ "replication-rebuild-required",
58
59
  ];
59
60
  /** A change the planner will not make. A plan with refusals cannot be applied. */
60
61
  export const MigrationRefusal = Schema.Struct({
@@ -1,4 +1,4 @@
1
- import { Effect } from "effect";
1
+ import { Effect, Schema } from "effect";
2
2
  import type { SqlClient } from "effect/unstable/sql";
3
3
  import type { OntologyManifest } from "../manifest.js";
4
4
  import { type ReplicationSelection, type ReplicationExclusion } from "./model.js";
@@ -9,6 +9,13 @@ export interface ReplicationTarget {
9
9
  readonly manifest: OntologyManifest;
10
10
  readonly selection: ReadonlyArray<ReplicationSelection>;
11
11
  }
12
+ declare const ReplicationBaselineFailed_base: Schema.Class<ReplicationBaselineFailed, Schema.TaggedStruct<"ReplicationBaselineFailed", {
13
+ readonly message: Schema.String;
14
+ readonly cause: Schema.optionalKey<Schema.Unknown>;
15
+ }>, import("effect/Cause").YieldableError>;
16
+ /** An initial snapshot cannot be created with the requested state or record projection. */
17
+ export declare class ReplicationBaselineFailed extends ReplicationBaselineFailed_base {
18
+ }
12
19
  /** All baseline rows are committed with capture enablement; interrupted setup can safely resume. */
13
20
  export declare const createReplicationBaseline: (sql: SqlClient.SqlClient, input: {
14
21
  readonly generation: string;
@@ -16,4 +23,5 @@ export declare const createReplicationBaseline: (sql: SqlClient.SqlClient, input
16
23
  readonly exclusions?: ReadonlyArray<ReplicationExclusion>;
17
24
  readonly selection: ReadonlyArray<ReplicationSelection>;
18
25
  readonly newSource?: boolean;
19
- }) => Effect.Effect<ReplicationTarget, import("effect/unstable/sql/SqlError").SqlError, never>;
26
+ }) => Effect.Effect<ReplicationTarget, ReplicationBaselineFailed | import("effect/unstable/sql/SqlError").SqlError, never>;
27
+ export {};
@@ -1,26 +1,35 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { Effect } from "effect";
2
+ import { Effect, Schema } from "effect";
3
3
  import { joinColumnNames, joinTableName, quoteIdentifier as qi, quoteLiteral as ql, tableName, } from "../migrate/storage.js";
4
4
  import { REPLICATION_RECORD_BYTES, validateReplicationSelection, } from "./model.js";
5
5
  import { snapshotExpression } from "./schema.js";
6
- const MAX_BASELINE_RECORDS = 50_000;
6
+ /** An initial snapshot cannot be created with the requested state or record projection. */
7
+ export class ReplicationBaselineFailed extends Schema.TaggedError()("ReplicationBaselineFailed", { message: Schema.String, cause: Schema.optionalKey(Schema.Unknown) }) {
8
+ }
7
9
  /** All baseline rows are committed with capture enablement; interrupted setup can safely resume. */
8
10
  export const createReplicationBaseline = (sql, input) => sql.withTransaction(Effect.gen(function* () {
9
11
  yield* sql.unsafe("SET LOCAL statement_timeout = '30s'");
10
12
  yield* sql.unsafe("SET LOCAL lock_timeout = '5s'");
11
13
  const [state] = yield* sql.unsafe("SELECT source_id::text, revision::text, enabled FROM _ontology_replication WHERE id FOR UPDATE");
12
14
  if (!state) {
13
- return yield* Effect.die(new Error("Run the ontology SDK migration before enabling replication."));
15
+ return yield* new ReplicationBaselineFailed({
16
+ message: "Run the ontology SDK migration before enabling replication.",
17
+ });
14
18
  }
15
19
  const existing = yield* sql.unsafe("SELECT * FROM _ontology_replication_targets WHERE generation = $1::uuid", [input.generation]);
16
20
  if (existing[0]) {
17
21
  return existing[0];
18
22
  }
19
23
  if (state.enabled) {
20
- return yield* Effect.die(new Error("Pause replication before starting a new generation."));
24
+ return yield* new ReplicationBaselineFailed({
25
+ message: "Pause replication before starting a new generation.",
26
+ });
21
27
  }
22
28
  const manifest = input.manifest;
23
- yield* Effect.sync(() => validateReplicationSelection(manifest, input.selection));
29
+ yield* Effect.try({
30
+ try: () => validateReplicationSelection(manifest, input.selection),
31
+ catch: (cause) => new ReplicationBaselineFailed({ message: "Invalid replication projection.", cause }),
32
+ });
24
33
  // Drain older SDK transactions that started before the compatibility fence was enabled.
25
34
  const tables = manifest.entities
26
35
  .filter((e) => e.source === undefined && !e.builtIn)
@@ -57,17 +66,17 @@ export const createReplicationBaseline = (sql, input) => sql.withTransaction(Eff
57
66
  JSON.stringify(manifest),
58
67
  JSON.stringify(input.selection),
59
68
  ]);
60
- let total = 0;
61
69
  const baseline = (entityId, kind, records) => Effect.gen(function* () {
62
70
  // Keep the snapshot in PostgreSQL; large baselines must not fill a serverless process's heap.
63
- const [size] = yield* sql.unsafe(`SELECT count(*)::int AS count, max(octet_length(payload::text)) AS largest
64
- FROM (${records} LIMIT ${MAX_BASELINE_RECORDS + 1}) snapshot`);
65
- total += size.count;
66
- if (total > MAX_BASELINE_RECORDS) {
67
- return yield* Effect.die(new Error("The replication baseline exceeds 50,000 records; use a bulk bootstrap."));
68
- }
69
- if ((size.largest ?? 0) > REPLICATION_RECORD_BYTES) {
70
- return yield* Effect.die(new Error("A projected baseline record exceeds the replication size limit."));
71
+ const [size] = yield* sql.unsafe(`SELECT EXISTS (
72
+ SELECT 1 FROM (${records}) snapshot
73
+ WHERE octet_length(payload::text) > ${REPLICATION_RECORD_BYTES}
74
+ ) AS oversized`);
75
+ if (size.oversized) {
76
+ return yield* new ReplicationBaselineFailed({
77
+ message: `A projected baseline record exceeds ${REPLICATION_RECORD_BYTES} bytes; ` +
78
+ "exclude large fields before enabling replication.",
79
+ });
71
80
  }
72
81
  yield* sql.unsafe(`INSERT INTO _ontology_replication_baselines (generation, event)
73
82
  SELECT $1::uuid, jsonb_build_object('source_id', $2::text, 'entity_id', $3::text,
@@ -0,0 +1,6 @@
1
+ import type { OntologyManifest } from "../manifest.js";
2
+ import type { MigrationRefusal } from "../migrate/model.js";
3
+ import type { ReplicationTarget } from "./baseline.js";
4
+ import type { ReplicationSelection } from "./model.js";
5
+ /** A live stream can change its projection only when retained JSON still describes current rows. */
6
+ export declare function replicationMigrationRefusals(current: ReplicationTarget, manifest: OntologyManifest, selection: ReadonlyArray<ReplicationSelection>): ReadonlyArray<MigrationRefusal>;
@@ -0,0 +1,64 @@
1
+ import { toSnakeCase } from "../names.js";
2
+ import { queryFields } from "../query.js";
3
+ /** A live stream can change its projection only when retained JSON still describes current rows. */
4
+ export function replicationMigrationRefusals(current, manifest, selection) {
5
+ const refusals = [];
6
+ const refuse = (path, reason) => {
7
+ refusals.push({
8
+ code: "replication-rebuild-required",
9
+ path,
10
+ message: `${reason} Pause replication, apply the migration, then explicitly rebuild replication.`,
11
+ });
12
+ };
13
+ for (const previous of current.selection) {
14
+ const entity = current.manifest.entities.find((item) => item.id === previous.entityId);
15
+ const next = selection.find((item) => item.entityId === entity.id);
16
+ if (!next) {
17
+ refuse(entity.name, "Removing a replicated entity would discard its pending history.");
18
+ continue;
19
+ }
20
+ for (const field of previous.fields) {
21
+ if (!next.fields.includes(field)) {
22
+ refuse(`${entity.name}.${field}`, "Removing or renaming a replicated field would leave retained JSON under its old name.");
23
+ }
24
+ }
25
+ for (const relationship of previous.relationships ?? []) {
26
+ if (!next.relationships?.includes(relationship)) {
27
+ refuse(`${entity.name}.${relationship}`, "Removing a replicated relationship would leave its old edges in retained JSON.");
28
+ }
29
+ }
30
+ }
31
+ for (const next of selection) {
32
+ const entity = manifest.entities.find((item) => item.id === next.entityId);
33
+ const previousEntity = current.manifest.entities.find((item) => item.id === entity.id);
34
+ // A newly created table starts empty and all subsequent writes are captured normally.
35
+ if (!previousEntity) {
36
+ continue;
37
+ }
38
+ const previous = current.selection.find((item) => item.entityId === entity.id);
39
+ if (!previous) {
40
+ refuse(entity.name, "Previously excluded records are absent from retained JSON.");
41
+ continue;
42
+ }
43
+ const previousFields = new Set(queryFields(previousEntity).map((field) => field.name));
44
+ for (const field of next.fields.filter((name) => !previous.fields.includes(name))) {
45
+ const property = entity.properties.find((item) => item.name === field);
46
+ const renamed = property?.renamedFrom;
47
+ if (previousFields.has(field) ||
48
+ (renamed !== undefined &&
49
+ (previousFields.has(renamed) || previousFields.has(toSnakeCase(renamed))))) {
50
+ refuse(`${entity.name}.${field}`, "Existing values are absent from this JSON field.");
51
+ }
52
+ else if (property?.default !== undefined && property.default !== null) {
53
+ refuse(`${entity.name}.${field}`, "Adding a field with a non-null default changes existing rows without captured writes.");
54
+ }
55
+ }
56
+ for (const relationship of next.relationships ?? []) {
57
+ if (!previous.relationships?.includes(relationship) &&
58
+ previousEntity.relationships.some((item) => item.name === relationship)) {
59
+ refuse(`${entity.name}.${relationship}`, "Previously excluded relationship edges are absent from retained JSON.");
60
+ }
61
+ }
62
+ }
63
+ return refusals;
64
+ }
@@ -1,5 +1,5 @@
1
1
  import { type OntologyManifest } from "../manifest.js";
2
- /** The resolved projection for one immutable generation. `id` is always included. */
2
+ /** The current projection of a continuous replication stream. `id` is always included. */
3
3
  export interface ReplicationSelection {
4
4
  readonly entityId: string;
5
5
  readonly fields: ReadonlyArray<string>;
@@ -1,14 +1,14 @@
1
1
  import { Effect } from "effect";
2
2
  import type { SqlClient } from "effect/unstable/sql";
3
3
  import { type ReplicationEvent, type ReplicationSelection, type ReplicationExclusion } from "./model.js";
4
- import { type ReplicationTarget } from "./baseline.js";
4
+ import { ReplicationBaselineFailed, type ReplicationTarget } from "./baseline.js";
5
5
  export type { ReplicationTarget } from "./baseline.js";
6
6
  export declare const startReplication: (sql: SqlClient.SqlClient, input: {
7
7
  readonly generation: string;
8
8
  readonly exclusions?: ReadonlyArray<ReplicationExclusion>;
9
9
  readonly selection?: ReadonlyArray<ReplicationSelection>;
10
10
  readonly newSource?: boolean;
11
- }) => Effect.Effect<ReplicationTarget, import("../index.js").CatalogInvalid | import("effect/unstable/sql/SqlError").SqlError, never>;
11
+ }) => Effect.Effect<ReplicationTarget, import("../index.js").CatalogInvalid | ReplicationBaselineFailed | import("effect/unstable/sql/SqlError").SqlError, never>;
12
12
  export declare const activeReplicationTarget: (sql: SqlClient.SqlClient) => Effect.Effect<ReplicationTarget, import("effect/unstable/sql/SqlError").SqlError, never>;
13
13
  export declare const pauseReplication: (sql: SqlClient.SqlClient) => import("effect/unstable/sql/Statement").Statement<object>;
14
14
  export declare const replicationTarget: (sql: SqlClient.SqlClient, generation: string) => Effect.Effect<ReplicationTarget, import("effect/unstable/sql/SqlError").SqlError, never>;
@@ -2,17 +2,22 @@ import { Effect } from "effect";
2
2
  import { readCatalog } from "../migrate/apply.js";
3
3
  import { historyTableName, quoteIdentifier as qi } from "../migrate/storage.js";
4
4
  import { REPLICATION_BATCH_BYTES, REPLICATION_BATCH_SIZE, publishMessage, replicationEventId, resolveReplicationSelection, } from "./model.js";
5
- import { createReplicationBaseline } from "./baseline.js";
5
+ import { createReplicationBaseline, ReplicationBaselineFailed, } from "./baseline.js";
6
6
  export const startReplication = (sql, input) => sql.withTransaction(Effect.gen(function* () {
7
7
  yield* sql.unsafe("SET LOCAL statement_timeout = '30s'");
8
8
  yield* sql.unsafe("SET LOCAL lock_timeout = '5s'");
9
9
  yield* sql.unsafe("SELECT id FROM _ontology_replication WHERE id FOR UPDATE");
10
10
  const manifest = yield* readCatalog({ sql });
11
11
  if (!manifest) {
12
- return yield* Effect.die(new Error("The ontology has not been migrated."));
12
+ return yield* new ReplicationBaselineFailed({
13
+ message: "The ontology has not been migrated.",
14
+ });
13
15
  }
14
16
  const selection = input.selection ??
15
- (yield* Effect.sync(() => resolveReplicationSelection(manifest, input.exclusions)));
17
+ (yield* Effect.try({
18
+ try: () => resolveReplicationSelection(manifest, input.exclusions),
19
+ catch: (cause) => new ReplicationBaselineFailed({ message: "Invalid replication exclusions.", cause }),
20
+ }));
16
21
  return yield* createReplicationBaseline(sql, { ...input, manifest, selection });
17
22
  }));
18
23
  export const activeReplicationTarget = (sql) => sql
@@ -0,0 +1,39 @@
1
+ import { Context, Effect, Schema } from "effect";
2
+ import { NotFound, PermissionDenied } from "../errors.js";
3
+ import type { OntologyManifest } from "../manifest.js";
4
+ import type { OntologyReads, Principal } from "../runtime.js";
5
+ export interface FileReference {
6
+ readonly entity: string;
7
+ readonly id: string;
8
+ readonly property?: string;
9
+ }
10
+ declare const UnsupportedFile_base: Schema.Class<UnsupportedFile, Schema.TaggedStruct<"UnsupportedFile", {
11
+ readonly message: Schema.String;
12
+ }>, import("effect/Cause").YieldableError>;
13
+ export declare class UnsupportedFile extends UnsupportedFile_base {
14
+ }
15
+ /** Resolve only declared file properties, checking permission before reading any record. */
16
+ export declare function resolveFileRecord(input: {
17
+ readonly manifest: OntologyManifest;
18
+ readonly reads: Pick<OntologyReads["Service"], "get">;
19
+ readonly principal: Principal;
20
+ readonly reference: FileReference;
21
+ }): Effect.Effect<{
22
+ entity: string;
23
+ id: string;
24
+ property: string;
25
+ file: {
26
+ readonly id: string;
27
+ readonly filename: string;
28
+ readonly contentType: string;
29
+ readonly size: number;
30
+ readonly createdAt: string;
31
+ };
32
+ }, PermissionDenied | NotFound | UnsupportedFile, never>;
33
+ declare const AgentFileRecords_base: Context.ServiceClass<AgentFileRecords, "@segmentstream-cloud/ontology/AgentFileRecords", {
34
+ readonly resolve: (reference: FileReference, principal: Principal) => ReturnType<typeof resolveFileRecord>;
35
+ }>;
36
+ /** File validation in an action's prepare, bound to its ontology and read runtime. */
37
+ export declare class AgentFileRecords extends AgentFileRecords_base {
38
+ }
39
+ export {};
@@ -0,0 +1,44 @@
1
+ import { Context, Effect, Schema } from "effect";
2
+ import { BlobValue } from "../blob.js";
3
+ import { NotFound, PermissionDenied } from "../errors.js";
4
+ import { isAllowed, permissionOf, READ_VERB } from "../permissions.js";
5
+ export class UnsupportedFile extends Schema.TaggedError()("UnsupportedFile", {
6
+ message: Schema.String,
7
+ }) {
8
+ }
9
+ /** Resolve only declared file properties, checking permission before reading any record. */
10
+ export function resolveFileRecord(input) {
11
+ return Effect.gen(function* () {
12
+ const { entity: entityName, id, property: propertyName } = input.reference;
13
+ const permission = permissionOf(entityName, READ_VERB);
14
+ if (!isAllowed(permission, input.principal)) {
15
+ return yield* new PermissionDenied({ permission, message: "The file requires read access." });
16
+ }
17
+ const entity = input.manifest.entities.find((candidate) => candidate.name === entityName);
18
+ if (entity === undefined) {
19
+ return yield* new NotFound({ entity: entityName, id, message: "File record not found." });
20
+ }
21
+ const properties = entity.properties.filter((candidate) => candidate.type === "blob");
22
+ const property = propertyName === undefined
23
+ ? properties.length === 1
24
+ ? properties[0]
25
+ : undefined
26
+ : properties.find((candidate) => candidate.name === propertyName);
27
+ if (property === undefined) {
28
+ return yield* new UnsupportedFile({
29
+ message: "Name a file property; the entity must have exactly one when property is omitted.",
30
+ });
31
+ }
32
+ const record = yield* input.reads.get({ entity: entityName, id });
33
+ const file = record[property.name];
34
+ if (file === null || file === undefined) {
35
+ return yield* new NotFound({ entity: entityName, id, message: "File not found." });
36
+ }
37
+ // Runtime records are decoded already; malformed stored metadata is an operator failure.
38
+ const decoded = yield* Schema.decodeUnknownEffect(BlobValue)(file).pipe(Effect.orDie);
39
+ return { entity: entityName, id, property: property.name, file: decoded };
40
+ });
41
+ }
42
+ /** File validation in an action's prepare, bound to its ontology and read runtime. */
43
+ export class AgentFileRecords extends Context.Service()("@segmentstream-cloud/ontology/AgentFileRecords") {
44
+ }
@@ -14,6 +14,7 @@ import { UnknownAction, ValidationIssue } from "./errors.js";
14
14
  import { generatedActions } from "./generated-actions.js";
15
15
  import { Edit } from "./history.js";
16
16
  import { makeOntologyRuntime } from "./sql-runtime.js";
17
+ import { AgentFileRecords, resolveFileRecord } from "./files.js";
17
18
  import { validate } from "./validation.js";
18
19
  /** How long `prepare` may take, connection calls included; each call has its own 30 s deadline. */
19
20
  export const PREPARE_TIMEOUT = Duration.seconds(60);
@@ -243,7 +244,9 @@ function prepare(ontology, sql, action, context, connections, qualified) {
243
244
  find: ({ entity, options }) => runtime.find({ entity, options: { ...options, forUpdate: false } }),
244
245
  };
245
246
  const run = Effect.suspend(() => action.prepare(context));
246
- return yield* run.pipe(Effect.provideService(OntologyReads, reads), Effect.provideContext(connectionContext(action.connections, transport ?? NO_CONNECTIONS)), Effect.timeoutOrElse({
247
+ return yield* run.pipe(Effect.provideService(OntologyReads, reads), Effect.provideService(AgentFileRecords, {
248
+ resolve: (reference, principal) => resolveFileRecord({ manifest: ontology.manifest, reads, reference, principal }),
249
+ }), Effect.provideContext(connectionContext(action.connections, transport ?? NO_CONNECTIONS)), Effect.timeoutOrElse({
247
250
  duration: PREPARE_TIMEOUT,
248
251
  orElse: () => Effect.fail(new ConnectionError({
249
252
  connection: action.connections.map((handle) => handle.key).join(","),
@@ -7,8 +7,9 @@ import { RequestId } from "../errors.js";
7
7
  import type { ApiPlan } from "../plan.js";
8
8
  import type { ReplicationKick } from "../replication.js";
9
9
  import { OntologyBackend } from "../runtime-contract.js";
10
- import { type RuntimeTokenVerifier } from "../runtime-identity.js";
10
+ import type { RuntimeTokenVerifier } from "../runtime-identity.js";
11
11
  import type { AgentChannelFactory } from "./channel.js";
12
+ export { CONVERSATION_HEADER, SESSION_HEADER } from "./authentication.js";
12
13
  /**
13
14
  * The agent's temporary attachments over HTTP (`docs/agent.md`, "Attachments"). Only the
14
15
  * deployment's runtime token reaches these routes, acting as the member in `X-SegmentStream-Act-As`
@@ -17,10 +18,6 @@ import type { AgentChannelFactory } from "./channel.js";
17
18
  * tool arguments; every operation is confined to that member, chat and session.
18
19
  */
19
20
  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
21
  export interface AttachmentRoutesOptions {
25
22
  readonly plan: ApiPlan;
26
23
  readonly hooks: ReadonlyMap<string, AfterCommitHook>;
@@ -1,14 +1,14 @@
1
- import { Cause, Effect, Layer, Redacted, Result, Schema } from "effect";
2
- import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
3
- import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
1
+ import { Cause, Effect, Layer, Result, Schema } from "effect";
2
+ import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
4
3
  import { ATTACHMENT_SOURCES, } from "../../storage/attachments.js";
5
4
  import { schemaIssues } from "../../runtime/validation.js";
6
5
  import { CurrentPrincipal } from "../authentication.js";
7
- import { RequestId, errorBodyForWire, failureResponse, forbidden, toServerResponse, unauthorized, unavailable, } from "../errors.js";
6
+ import { RequestId, errorBodyForWire, failureResponse } from "../errors.js";
8
7
  import { identityToken, makeOperations } from "../operations.js";
9
8
  import { OntologyBackend } from "../runtime-contract.js";
10
- import { ACT_AS_HEADER, logRuntimeRejection, } from "../runtime-identity.js";
9
+ import { authenticateAgentCaller } from "./authentication.js";
11
10
  import { AGENT_PATH } from "./server.js";
11
+ export { CONVERSATION_HEADER, SESSION_HEADER } from "./authentication.js";
12
12
  /**
13
13
  * The agent's temporary attachments over HTTP (`docs/agent.md`, "Attachments"). Only the
14
14
  * deployment's runtime token reaches these routes, acting as the member in `X-SegmentStream-Act-As`
@@ -17,12 +17,6 @@ import { AGENT_PATH } from "./server.js";
17
17
  * tool arguments; every operation is confined to that member, chat and session.
18
18
  */
19
19
  export const ATTACHMENTS_PATH = `${AGENT_PATH}/attachments`;
20
- /** The chat an attachment belongs to, such as `telegram:12345`. */
21
- export const CONVERSATION_HEADER = "x-segmentstream-agent-conversation";
22
- /** The agent session the request comes from. */
23
- export const SESSION_HEADER = "x-segmentstream-agent-session";
24
- const CONVERSATION = /^[a-z][a-z0-9_-]{0,31}:[A-Za-z0-9:_-]{1,200}$/;
25
- const SESSION = /^[A-Za-z0-9:_.-]{1,200}$/;
26
20
  const NO_STORE = { "cache-control": "no-store" };
27
21
  const Received = Schema.Struct({
28
22
  source: Schema.Literals(ATTACHMENT_SOURCES),
@@ -111,39 +105,16 @@ export function attachmentsLayer(options) {
111
105
  return;
112
106
  }
113
107
  const operations = makeOperations(backend, options.plan, options.hooks, options.connections, options.replicate, options.agentChannel);
114
- const credential = HttpApiBuilder.securityDecode(HttpApiSecurity.bearer);
115
108
  /** Authenticates the runtime, then the member it acts for, the chat and the session. */
116
109
  const asAgent = (answer) => (request) => Effect.gen(function* () {
117
- const requestId = yield* RequestId;
118
- const bearer = yield* Effect.provideService(credential, HttpServerRequest.HttpServerRequest, request);
119
- const token = Redacted.value(bearer);
120
- const verified = token === "" ? undefined : yield* Effect.promise(() => verifyRuntime(token));
121
- switch (verified?.status) {
122
- case "valid":
123
- break;
124
- case "invalid":
125
- yield* logRuntimeRejection(verified.reason);
126
- return toServerResponse(unauthorized(requestId, "The bearer token is invalid, expired or revoked."));
127
- case "unavailable":
128
- return toServerResponse(unavailable(requestId));
129
- default:
130
- return toServerResponse(forbidden(requestId, "Only the app's agent calls this route."));
131
- }
132
- const actAs = request.headers[ACT_AS_HEADER]?.trim() ?? "";
133
- const conversation = request.headers[CONVERSATION_HEADER]?.trim() ?? "";
134
- const session = request.headers[SESSION_HEADER]?.trim() ?? "";
135
- const member = actAs === "" ? undefined : yield* agents.actingMember(actAs);
136
- if (member === undefined) {
137
- return toServerResponse(forbidden(requestId, "The member is inactive or has not paired with the agent."));
138
- }
139
- if (!CONVERSATION.test(conversation) || (session !== "" && !SESSION.test(session))) {
140
- return toServerResponse(forbidden(requestId, "The agent names the chat and session of every request."));
141
- }
142
- return yield* answer(request, {
143
- principal: member,
144
- conversation,
145
- ...(session === "" ? {} : { sessionId: session }),
146
- }, attachments);
110
+ const authenticated = yield* authenticateAgentCaller({
111
+ request,
112
+ agents,
113
+ verify: verifyRuntime,
114
+ });
115
+ return authenticated.ok
116
+ ? yield* answer(request, authenticated.caller, attachments)
117
+ : authenticated.response;
147
118
  });
148
119
  const idOf = Effect.map(HttpRouter.params, (params) => params.id ?? "");
149
120
  yield* router.add("POST", ATTACHMENTS_PATH, asAgent((request, caller, runtime) => answered(Effect.gen(function* () {
@@ -0,0 +1,20 @@
1
+ import { Effect } from "effect";
2
+ import { HttpServerRequest } from "effect/unstable/http";
3
+ import type { AttachmentCaller } from "../../storage/attachments.js";
4
+ import { RequestId } from "../errors.js";
5
+ import type { AgentStore } from "../runtime-contract.js";
6
+ import { type RuntimeTokenVerifier } from "../runtime-identity.js";
7
+ export declare const CONVERSATION_HEADER = "x-segmentstream-agent-conversation";
8
+ export declare const SESSION_HEADER = "x-segmentstream-agent-session";
9
+ /** Both temporary attachments and stored files require the runtime and its paired member. */
10
+ export declare function authenticateAgentCaller(input: {
11
+ readonly request: HttpServerRequest.HttpServerRequest;
12
+ readonly agents: AgentStore;
13
+ readonly verify: RuntimeTokenVerifier;
14
+ }): Effect.Effect<{
15
+ readonly ok: false;
16
+ readonly response: import("effect/unstable/http/HttpServerResponse").HttpServerResponse;
17
+ } | {
18
+ readonly ok: true;
19
+ readonly caller: AttachmentCaller;
20
+ }, never, RequestId | HttpServerRequest.ParsedSearchParams>;
@@ -0,0 +1,48 @@
1
+ import { Effect, Redacted } from "effect";
2
+ import { HttpServerRequest } from "effect/unstable/http";
3
+ import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
4
+ import { RequestId, forbidden, toServerResponse, unauthorized, unavailable } from "../errors.js";
5
+ import { ACT_AS_HEADER, logRuntimeRejection, } from "../runtime-identity.js";
6
+ export const CONVERSATION_HEADER = "x-segmentstream-agent-conversation";
7
+ export const SESSION_HEADER = "x-segmentstream-agent-session";
8
+ const CONVERSATION = /^[a-z][a-z0-9_-]{0,31}:[A-Za-z0-9:_-]{1,200}$/;
9
+ const SESSION = /^[A-Za-z0-9:_.-]{1,200}$/;
10
+ /** Both temporary attachments and stored files require the runtime and its paired member. */
11
+ export function authenticateAgentCaller(input) {
12
+ return Effect.gen(function* () {
13
+ const { request, agents, verify } = input;
14
+ const requestId = yield* RequestId;
15
+ const credential = HttpApiBuilder.securityDecode(HttpApiSecurity.bearer);
16
+ const bearer = yield* Effect.provideService(credential, HttpServerRequest.HttpServerRequest, request);
17
+ const token = Redacted.value(bearer);
18
+ const verified = token === "" ? undefined : yield* Effect.promise(() => verify(token));
19
+ const denied = (response) => ({ ok: false, response: toServerResponse(response) });
20
+ switch (verified?.status) {
21
+ case "valid":
22
+ break;
23
+ case "invalid":
24
+ yield* logRuntimeRejection(verified.reason);
25
+ return denied(unauthorized(requestId, "The bearer token is invalid, expired or revoked."));
26
+ case "unavailable":
27
+ return denied(unavailable(requestId));
28
+ default:
29
+ return denied(forbidden(requestId, "Only the app's agent calls this route."));
30
+ }
31
+ const actAs = request.headers[ACT_AS_HEADER]?.trim() ?? "";
32
+ const conversation = request.headers[CONVERSATION_HEADER]?.trim() ?? "";
33
+ const session = request.headers[SESSION_HEADER]?.trim() ?? "";
34
+ const member = actAs === "" ? undefined : yield* agents.actingMember(actAs);
35
+ if (member === undefined) {
36
+ return denied(forbidden(requestId, "The member is inactive or has not paired with the agent."));
37
+ }
38
+ if (!CONVERSATION.test(conversation) || (session !== "" && !SESSION.test(session))) {
39
+ return denied(forbidden(requestId, "The agent names the chat and session of every request."));
40
+ }
41
+ const caller = {
42
+ principal: member,
43
+ conversation,
44
+ ...(session === "" ? {} : { sessionId: session }),
45
+ };
46
+ return { ok: true, caller };
47
+ });
48
+ }
@@ -0,0 +1,12 @@
1
+ import { Layer } from "effect";
2
+ import { HttpRouter } from "effect/unstable/http";
3
+ import { RequestId } from "../errors.js";
4
+ import type { ApiPlan } from "../plan.js";
5
+ import { OntologyBackend } from "../runtime-contract.js";
6
+ import type { RuntimeTokenVerifier } from "../runtime-identity.js";
7
+ export declare const FILES_PATH = "/api/ontology/agent/files";
8
+ /** A short-lived download grant, issued only after the runtime's member may read the file. */
9
+ export declare function filesLayer(options: {
10
+ readonly plan: ApiPlan;
11
+ readonly verifyRuntime: RuntimeTokenVerifier | undefined;
12
+ }): Layer.Layer<never, never, OntologyBackend | HttpRouter.HttpRouter | HttpRouter.Request<"Requires", RequestId>>;
@@ -0,0 +1,69 @@
1
+ import { Cause, Effect, Layer } from "effect";
2
+ import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
3
+ import { resolveFileRecord } from "../../runtime/files.js";
4
+ import { isUuid } from "../../query.js";
5
+ import { RequestId, failureResponse, notFound, toServerResponse } from "../errors.js";
6
+ import { OntologyBackend } from "../runtime-contract.js";
7
+ import { authenticateAgentCaller } from "./authentication.js";
8
+ import { AGENT_PATH } from "./server.js";
9
+ export const FILES_PATH = `${AGENT_PATH}/files`;
10
+ /** A short-lived download grant, issued only after the runtime's member may read the file. */
11
+ export function filesLayer(options) {
12
+ return Layer.effectDiscard(Effect.gen(function* () {
13
+ const router = yield* HttpRouter.HttpRouter;
14
+ const backend = yield* OntologyBackend;
15
+ const { blobs, agents } = backend;
16
+ const verify = options.verifyRuntime;
17
+ if (blobs === undefined || agents === undefined || verify === undefined) {
18
+ return;
19
+ }
20
+ const read = (request) => Effect.gen(function* () {
21
+ const requestId = yield* RequestId;
22
+ const authenticated = yield* authenticateAgentCaller({ request, agents, verify });
23
+ if (!authenticated.ok) {
24
+ return authenticated.response;
25
+ }
26
+ const params = yield* HttpRouter.params;
27
+ const id = params.id ?? "";
28
+ if (!isUuid(id)) {
29
+ return toServerResponse(notFound(requestId, "File record not found."));
30
+ }
31
+ const resolved = yield* resolveFileRecord({
32
+ manifest: options.plan.ontology.manifest,
33
+ reads: backend,
34
+ principal: authenticated.caller.principal,
35
+ reference: {
36
+ entity: params.entity ?? "",
37
+ id,
38
+ ...(params.property === undefined ? {} : { property: params.property }),
39
+ },
40
+ });
41
+ // Pin the revision read above. download rechecks live permissions in its transaction.
42
+ const download = yield* blobs.download(resolved.entity, resolved.id, resolved.property, { revisionId: resolved.file.id }, authenticated.caller.principal);
43
+ return HttpServerResponse.jsonUnsafe({
44
+ file: {
45
+ id,
46
+ entity: resolved.entity,
47
+ property: resolved.property,
48
+ filename: resolved.file.filename,
49
+ content_type: resolved.file.contentType,
50
+ size: resolved.file.size,
51
+ },
52
+ download: {
53
+ url: download.url,
54
+ method: download.method,
55
+ headers: download.headers,
56
+ expires_at: download.expiresAt,
57
+ },
58
+ }, { headers: { "cache-control": "no-store" } });
59
+ }).pipe(Effect.catchTag("UnsupportedFile", (error) => Effect.map(RequestId, (requestId) => HttpServerResponse.jsonUnsafe({
60
+ error: "unsupported",
61
+ detail: error.message,
62
+ request_id: requestId,
63
+ }, { status: 422 }))), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause)
64
+ ? Effect.failCause(cause)
65
+ : Effect.flatMap(RequestId, (requestId) => failureResponse(cause, requestId))));
66
+ yield* router.add("GET", `${FILES_PATH}/:entity/:id`, read);
67
+ yield* router.add("GET", `${FILES_PATH}/:entity/:id/:property`, read);
68
+ }));
69
+ }
@@ -170,7 +170,13 @@ function seeChat(store) {
170
170
  if (Result.isFailure(decoded)) {
171
171
  return invalidBody(requestId, schemaIssues(decoded.failure));
172
172
  }
173
- return json(yield* store.seeChat(decoded.success));
173
+ const chat = yield* store.seeChat(decoded.success);
174
+ yield* Effect.logInfo("ontology.agent.chat_reported").pipe(Effect.annotateLogs({
175
+ requestId,
176
+ provider: decoded.success.provider,
177
+ chatId: decoded.success.externalId,
178
+ }));
179
+ return json(chat);
174
180
  });
175
181
  }
176
182
  /** `GET /api/ontology/agent/identities` (member): the member's own live identities. */
@@ -5,6 +5,7 @@ import { storageMaintenanceAuthenticationLayer } from "../storage/maintenance-au
5
5
  import { nameToWire } from "../authoring-names.js";
6
6
  import { sqlServiceRuntime } from "../runtime/service-binding.js";
7
7
  import { attachmentsLayer } from "./agent/attachments.js";
8
+ import { filesLayer } from "./agent/files.js";
8
9
  import { platformConversations } from "./agent/conversations.js";
9
10
  import { conversationRoutes } from "./agent/conversation-routes.js";
10
11
  import { deployedAgentChannel } from "./agent/channel.js";
@@ -130,7 +131,7 @@ export function createOntologyService(ontology, config) {
130
131
  verifyToken,
131
132
  verifyRuntime,
132
133
  });
133
- const application = Layer.mergeAll(routes, mcp, agent, attachments, conversations === undefined
134
+ const application = Layer.mergeAll(routes, mcp, agent, attachments, filesLayer({ plan: built.plan, verifyRuntime }), conversations === undefined
134
135
  ? Layer.empty
135
136
  : conversationRoutes({
136
137
  transport: conversations,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream-cloud/ontology",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Define a SegmentStream workspace app's ontology: entities, relationships, roles and actions, written with Effect.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SegmentStream",