@segmentstream-cloud/ontology 0.25.0 → 0.26.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.
@@ -1,9 +1,11 @@
1
- import { Effect, Option } from "effect";
1
+ import { Effect, Exit, Option } from "effect";
2
2
  import { beginReplicationWrite } from "../replication/transaction.js";
3
3
  import { isSqlError } from "effect/unstable/sql/SqlError";
4
- import { Conflict } from "../errors.js";
4
+ import { ActionRejected, Conflict } from "../errors.js";
5
5
  import { toSnakeCase } from "../names.js";
6
6
  import { User } from "../user.js";
7
+ import { ACTION_MAX_BATCH_ROWS, BATCH_MAX_BYTES, BATCH_MAX_ROWS, batchInputBytes, executeBatch, planBatch, } from "./batch.js";
8
+ import { logBatchConflict } from "./batch-diagnostics.js";
7
9
  import { InvalidQuery, WriteOutsideAction } from "./errors.js";
8
10
  import { entityModel, ontologyModel } from "./model.js";
9
11
  import { loadRecord, notFound, readHistory, relatedCondition, runQuery, validateQuery, } from "./reads.js";
@@ -23,7 +25,7 @@ function withoutUndefined(value) {
23
25
  .map(([key, entry]) => [key, withoutUndefined(entry)]));
24
26
  }
25
27
  /** A unique violation becomes Conflict, naming the property when the index covers one column. */
26
- function uniqueConflict(entity, error) {
28
+ function uniqueConflict(entity, error, batch) {
27
29
  const cause = (error.reason.cause ?? {});
28
30
  const unique = error.reason._tag === "UniqueViolation" || cause.code === "23505";
29
31
  if (!unique) {
@@ -31,17 +33,36 @@ function uniqueConflict(entity, error) {
31
33
  }
32
34
  const columns = typeof cause.detail === "string" ? /^Key \(([^)]*)\)=/.exec(cause.detail)?.[1] : undefined;
33
35
  const field = entity.fields.find((candidate) => candidate.column === columns);
36
+ const fields = typeof columns === "string"
37
+ ? columns.split(", ").flatMap((column) => {
38
+ const candidate = entity.fields.find((entry) => entry.column === column);
39
+ return candidate === undefined ? [] : [candidate.relationship ?? candidate.name];
40
+ })
41
+ : [];
42
+ const details = batch ? { batch: { indices: [], ...(fields.length > 0 ? { fields } : {}) } } : {};
34
43
  const label = entity.label.toLowerCase();
35
- return Effect.fail(field === undefined
44
+ const conflict = field === undefined
36
45
  ? new Conflict({
37
46
  entity: entity.name,
38
47
  message: `The ${label} conflicts with an existing one.`,
48
+ ...details,
39
49
  })
40
50
  : new Conflict({
41
51
  entity: entity.name,
42
52
  property: field.name,
43
53
  message: `A ${label} with this ${field.name} already exists.`,
44
- }));
54
+ ...details,
55
+ });
56
+ if (batch) {
57
+ // Keep the driver's complete cause available to operators without serializing SQL or values.
58
+ Object.defineProperty(conflict, "cause", { value: error, enumerable: false });
59
+ return logBatchConflict(entity, error, {
60
+ operation: batch.operation,
61
+ action: batch.scope.action,
62
+ actionId: batch.scope.actionId,
63
+ }).pipe(Effect.andThen(Effect.fail(conflict)));
64
+ }
65
+ return Effect.fail(conflict);
45
66
  }
46
67
  /**
47
68
  * Platform identity bookkeeping touches only User properties and their history. User is excluded
@@ -91,6 +112,8 @@ export function makeOntologyRuntime(ontology, sql, scope) {
91
112
  run: (text, params) => client.unsafe(text, params.values),
92
113
  };
93
114
  const entityOf = (name) => Effect.sync(() => entityModel(model, name));
115
+ let attemptedBatchRows = 0;
116
+ let attributionInitialized = false;
94
117
  const write = (entityName, operation) => Effect.gen(function* () {
95
118
  const entity = yield* entityOf(entityName);
96
119
  if (scope === undefined) {
@@ -108,14 +131,111 @@ export function makeOntologyRuntime(ontology, sql, scope) {
108
131
  for (const edit of edits) {
109
132
  scope.onEdit?.(edit);
110
133
  }
134
+ if (edits.length > 0 && scope.execution !== undefined) {
135
+ attributionInitialized = true;
136
+ }
111
137
  return result;
112
138
  });
139
+ const batch = (entityName, operation, input) => Effect.suspend(() => {
140
+ const startedAt = Date.now();
141
+ let statements = 0;
142
+ let distinctReferences = 0;
143
+ let counts = { created: 0, updated: 0, unchanged: 0 };
144
+ const run = Effect.gen(function* () {
145
+ if (scope === undefined) {
146
+ return yield* Effect.die(new WriteOutsideAction({
147
+ entity: entityName,
148
+ message: `Writes to ${entityName} run inside an action, which history records.`,
149
+ }));
150
+ }
151
+ const declared = ontology.entities.find((entity) => entity.name === entityName);
152
+ if (declared?.builtIn === true || declared?.entity.definition.source !== undefined) {
153
+ return yield* new ActionRejected({
154
+ message: "Batch writes require an ordinary stored entity.",
155
+ batch: { indices: [] },
156
+ });
157
+ }
158
+ const entity = yield* entityOf(entityName);
159
+ const length = input.records.length;
160
+ if (length > 0) {
161
+ attemptedBatchRows += length;
162
+ }
163
+ if (length > BATCH_MAX_ROWS || (length > 0 && attemptedBatchRows > ACTION_MAX_BATCH_ROWS)) {
164
+ return yield* new ActionRejected({
165
+ message: length > BATCH_MAX_ROWS
166
+ ? `A batch accepts at most ${BATCH_MAX_ROWS} records.`
167
+ : `An action accepts at most ${ACTION_MAX_BATCH_ROWS} attempted batch records.`,
168
+ batch: { indices: [] },
169
+ });
170
+ }
171
+ if (batchInputBytes(input) > BATCH_MAX_BYTES) {
172
+ return yield* new ActionRejected({
173
+ message: `A batch accepts at most ${BATCH_MAX_BYTES} UTF-8 JSON bytes.`,
174
+ batch: { indices: [] },
175
+ });
176
+ }
177
+ const plan = yield* planBatch(entity, operation, input);
178
+ distinctReferences = plan.distinctReferences;
179
+ if (length === 0) {
180
+ return plan.returning === "summary" ? counts : { ...counts, items: [] };
181
+ }
182
+ const enclosingTransaction = yield* Effect.serviceOption(sql.transactionService);
183
+ if (Option.isNone(enclosingTransaction)) {
184
+ return yield* Effect.die(new WriteOutsideAction({
185
+ entity: entityName,
186
+ message: "Batch writes require the enclosing action transaction.",
187
+ }));
188
+ }
189
+ const edits = [];
190
+ statements += 1; // The SQL client's nested transaction issues one savepoint.
191
+ const result = yield* sql
192
+ .withTransaction((scope.replicationInitialized ? Effect.void : beginReplicationWrite(sql)).pipe(Effect.andThen(executeBatch({
193
+ ...context,
194
+ scope,
195
+ edits,
196
+ attributionInitialized,
197
+ run: (text, params) => {
198
+ statements += 1;
199
+ return context.run(text, params);
200
+ },
201
+ }, entity, plan)), Effect.onExit((exit) => Effect.sync(() => {
202
+ if (Exit.isFailure(exit)) {
203
+ statements += 1; // A failed nested transaction rolls back to its savepoint.
204
+ }
205
+ }))))
206
+ .pipe(Effect.catchIf(isSqlError, (error) => uniqueConflict(entity, error, { operation, scope })));
207
+ counts = { created: result.created, updated: result.updated, unchanged: result.unchanged };
208
+ for (const edit of edits) {
209
+ scope.onEdit?.(edit);
210
+ }
211
+ if (edits.length > 0 && scope.execution !== undefined) {
212
+ attributionInitialized = true;
213
+ }
214
+ return result;
215
+ });
216
+ return run.pipe(Effect.onExit((exit) => Effect.logDebug("ontology.action.batch_completed").pipe(Effect.annotateLogs({
217
+ operation,
218
+ entity: entityName,
219
+ action: scope?.action,
220
+ actionId: scope?.actionId,
221
+ inputCount: input.records.length,
222
+ distinctReferenceCount: distinctReferences,
223
+ changedCount: counts.created + counts.updated,
224
+ unchangedCount: counts.unchanged,
225
+ sqlStatements: statements,
226
+ batchMs: Date.now() - startedAt,
227
+ outcome: Exit.isSuccess(exit) ? "success" : "failure",
228
+ }))));
229
+ });
113
230
  const query = (input) => Effect.gen(function* () {
114
231
  const entity = yield* entityOf(input.entity);
115
232
  const validated = yield* validateQuery(entity, input.query);
116
233
  return yield* orDieOnSql(runQuery(context, entity, validated));
117
234
  });
118
235
  return {
236
+ createMany: ((input) => batch(input.entity, "create", input)),
237
+ updateMany: ((input) => batch(input.entity, "update", input)),
238
+ upsertMany: ((input) => batch(input.entity, "upsert", input)),
119
239
  get: (input) => orDieOnSql(Effect.gen(function* () {
120
240
  const entity = yield* entityOf(input.entity);
121
241
  const record = yield* loadRecord(context, entity, input.id, input.forUpdate === true ? "update" : undefined);
package/dist/runtime.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Context, Effect } from "effect";
2
- import type { Conflict, NotFound } from "./errors.js";
2
+ import type { ActionRejected, Conflict, NotFound } from "./errors.js";
3
3
  import type { Where } from "./query.js";
4
4
  import type { WorkloadExecution } from "./runtime/workload.js";
5
5
  /** Who runs an action: an app member or a service principal, both `user` records. */
@@ -76,6 +76,51 @@ export interface UpsertResult<R = StoredRecord> {
76
76
  readonly record: R;
77
77
  readonly created: boolean;
78
78
  }
79
+ /** Whether a batch returns its records or only outcome counts. Defaults to `records`. */
80
+ export type BatchReturning = "records" | "summary";
81
+ export interface BatchOptions {
82
+ readonly returning?: BatchReturning;
83
+ }
84
+ /** Outcome counts, which together equal the number of inputs in a successful batch. */
85
+ export interface BatchSummaryResult {
86
+ readonly created: number;
87
+ readonly updated: number;
88
+ readonly unchanged: number;
89
+ }
90
+ /** One result per input, in input order, including records whose values did not change. */
91
+ export interface BatchRecordResult<R = StoredRecord> extends BatchSummaryResult {
92
+ readonly items: ReadonlyArray<{
93
+ readonly index: number;
94
+ readonly status: "created" | "updated" | "unchanged";
95
+ readonly record: R;
96
+ }>;
97
+ }
98
+ export type BatchResult<R = StoredRecord> = BatchRecordResult<R> | BatchSummaryResult;
99
+ /** Raw batch inputs use wire entity and field names. */
100
+ export interface CreateManyInput extends BatchOptions {
101
+ readonly entity: string;
102
+ readonly records: ReadonlyArray<StoredValues>;
103
+ }
104
+ export interface UpdateManyInput extends BatchOptions {
105
+ readonly entity: string;
106
+ readonly records: ReadonlyArray<{
107
+ readonly id: string;
108
+ readonly values: StoredValues;
109
+ }>;
110
+ }
111
+ export interface UpsertManyInput extends CreateManyInput {
112
+ readonly on: readonly [string, ...string[]];
113
+ }
114
+ type BatchWriteError = ActionRejected | Conflict | NotFound;
115
+ interface BatchWrite<Input extends BatchOptions> {
116
+ (input: Input & {
117
+ readonly returning: "summary";
118
+ }): Effect.Effect<BatchSummaryResult, BatchWriteError>;
119
+ (input: Input & {
120
+ readonly returning?: "records";
121
+ }): Effect.Effect<BatchRecordResult, BatchWriteError>;
122
+ (input: Input): Effect.Effect<BatchResult, BatchWriteError>;
123
+ }
79
124
  type ReadsService = {
80
125
  readonly get: (input: {
81
126
  readonly entity: string;
@@ -88,6 +133,9 @@ type ReadsService = {
88
133
  }) => Effect.Effect<ReadonlyArray<StoredRecord>>;
89
134
  };
90
135
  type WritesService = {
136
+ readonly createMany: BatchWrite<CreateManyInput>;
137
+ readonly updateMany: BatchWrite<UpdateManyInput>;
138
+ readonly upsertMany: BatchWrite<UpsertManyInput>;
91
139
  readonly create: (input: {
92
140
  readonly entity: string;
93
141
  readonly values: StoredValues;
@@ -130,7 +178,8 @@ export declare class OntologyReads extends OntologyReads_base {
130
178
  declare const OntologyWrites_base: Context.ServiceClass<OntologyWrites, "@segmentstream-cloud/ontology/OntologyWrites", WritesService>;
131
179
  /**
132
180
  * The writes an action handler runs, inside the action's transaction: `create`, `update`,
133
- * `upsertBy`, `delete`, `link` and `unlink` of entity handles require it. `prepare` never has it.
181
+ * `upsertBy`, the batch methods, `delete`, `link` and `unlink` of entity handles require it.
182
+ * `prepare` never has it.
134
183
  */
135
184
  export declare class OntologyWrites extends OntologyWrites_base {
136
185
  }
package/dist/runtime.js CHANGED
@@ -8,7 +8,8 @@ export class OntologyReads extends Context.Service()("@segmentstream-cloud/ontol
8
8
  }
9
9
  /**
10
10
  * The writes an action handler runs, inside the action's transaction: `create`, `update`,
11
- * `upsertBy`, `delete`, `link` and `unlink` of entity handles require it. `prepare` never has it.
11
+ * `upsertBy`, the batch methods, `delete`, `link` and `unlink` of entity handles require it.
12
+ * `prepare` never has it.
12
13
  */
13
14
  export class OntologyWrites extends Context.Service()("@segmentstream-cloud/ontology/OntologyWrites") {
14
15
  }
@@ -13,9 +13,10 @@ export { CONVERSATION_HEADER, SESSION_HEADER } from "./authentication.js";
13
13
  /**
14
14
  * The agent's temporary attachments over HTTP (`docs/agent.md`, "Attachments"). Only the
15
15
  * deployment's runtime token reaches these routes, acting as the member in `X-SegmentStream-Act-As`
16
- * while they are active and paired. The chat and the agent session come from the agent's runtime in
16
+ * while they are active. The chat and the agent session come from the agent's runtime in
17
17
  * `X-SegmentStream-Agent-Conversation` and `X-SegmentStream-Agent-Session`, never from the model's
18
- * tool arguments; every operation is confined to that member, chat and session.
18
+ * tool arguments. Storage checks current group participation or chat administration; private files
19
+ * remain confined to their sender and session.
19
20
  */
20
21
  export declare const ATTACHMENTS_PATH = "/api/ontology/agent/attachments";
21
22
  export interface AttachmentRoutesOptions {
@@ -12,9 +12,10 @@ 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`
15
- * while they are active and paired. The chat and the agent session come from the agent's runtime in
15
+ * while they are active. The chat and the agent session come from the agent's runtime in
16
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.
17
+ * tool arguments. Storage checks current group participation or chat administration; private files
18
+ * remain confined to their sender and session.
18
19
  */
19
20
  export const ATTACHMENTS_PATH = `${AGENT_PATH}/attachments`;
20
21
  const NO_STORE = { "cache-control": "no-store" };
@@ -111,6 +112,7 @@ export function attachmentsLayer(options) {
111
112
  request,
112
113
  agents,
113
114
  verify: verifyRuntime,
115
+ allowUnpairedMember: true,
114
116
  });
115
117
  return authenticated.ok
116
118
  ? yield* answer(request, authenticated.caller, attachments)
@@ -6,11 +6,13 @@ import type { AgentStore } from "../runtime-contract.js";
6
6
  import { type RuntimeTokenVerifier } from "../runtime-identity.js";
7
7
  export declare const CONVERSATION_HEADER = "x-segmentstream-agent-conversation";
8
8
  export declare const SESSION_HEADER = "x-segmentstream-agent-session";
9
- /** Both temporary attachments and stored files require the runtime and its paired member. */
9
+ /** Authenticate the runtime and resolve its member; attachment storage also checks chat access. */
10
10
  export declare function authenticateAgentCaller(input: {
11
11
  readonly request: HttpServerRequest.HttpServerRequest;
12
12
  readonly agents: AgentStore;
13
13
  readonly verify: RuntimeTokenVerifier;
14
+ /** Temporary attachments authorize active members against the chat registry, without a pairing. */
15
+ readonly allowUnpairedMember?: boolean;
14
16
  }): Effect.Effect<{
15
17
  readonly ok: false;
16
18
  readonly response: import("effect/unstable/http/HttpServerResponse").HttpServerResponse;
@@ -1,13 +1,13 @@
1
1
  import { Effect, Redacted } from "effect";
2
2
  import { HttpServerRequest } from "effect/unstable/http";
3
3
  import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
4
- import { RequestId, forbidden, toServerResponse, unauthorized, unavailable } from "../errors.js";
4
+ import { RequestId, forbidden, notFound, toServerResponse, unauthorized, unavailable, } from "../errors.js";
5
5
  import { ACT_AS_HEADER, logRuntimeRejection, } from "../runtime-identity.js";
6
6
  export const CONVERSATION_HEADER = "x-segmentstream-agent-conversation";
7
7
  export const SESSION_HEADER = "x-segmentstream-agent-session";
8
8
  const CONVERSATION = /^[a-z][a-z0-9_-]{0,31}:[A-Za-z0-9:_-]{1,200}$/;
9
9
  const SESSION = /^[A-Za-z0-9:_.-]{1,200}$/;
10
- /** Both temporary attachments and stored files require the runtime and its paired member. */
10
+ /** Authenticate the runtime and resolve its member; attachment storage also checks chat access. */
11
11
  export function authenticateAgentCaller(input) {
12
12
  return Effect.gen(function* () {
13
13
  const { request, agents, verify } = input;
@@ -31,9 +31,15 @@ export function authenticateAgentCaller(input) {
31
31
  const actAs = request.headers[ACT_AS_HEADER]?.trim() ?? "";
32
32
  const conversation = request.headers[CONVERSATION_HEADER]?.trim() ?? "";
33
33
  const session = request.headers[SESSION_HEADER]?.trim() ?? "";
34
- const member = actAs === "" ? undefined : yield* agents.actingMember(actAs);
34
+ const member = actAs === ""
35
+ ? undefined
36
+ : input.allowUnpairedMember
37
+ ? (yield* agents.member(actAs))?.principal
38
+ : yield* agents.actingMember(actAs);
35
39
  if (member === undefined) {
36
- return denied(forbidden(requestId, "The member is inactive or has not paired with the agent."));
40
+ return denied(input.allowUnpairedMember
41
+ ? notFound(requestId, "Attachment not found.")
42
+ : forbidden(requestId, "The member is inactive or has not paired with the agent."));
37
43
  }
38
44
  if (!CONVERSATION.test(conversation) || (session !== "" && !SESSION.test(session))) {
39
45
  return denied(forbidden(requestId, "The agent names the chat and session of every request."));
@@ -1,6 +1,7 @@
1
1
  import { Cause, Context, Effect, Option, Predicate, Schema, SchemaIssue } from "effect";
2
2
  import { HttpServerResponse } from "effect/unstable/http";
3
3
  import { HttpApiError } from "effect/unstable/httpapi";
4
+ import { BatchErrorDetails } from "../errors.js";
4
5
  /** The correlation id of the current request, in every error body and the `x-request-id` header. */
5
6
  export class RequestId extends Context.Service()("@segmentstream-cloud/ontology/RequestId") {
6
7
  }
@@ -64,6 +65,19 @@ function listed(values) {
64
65
  ? values.join("")
65
66
  : `${values.slice(0, -1).join(", ")} and ${values.at(-1)}`;
66
67
  }
68
+ /** Only the declared safe locations cross a transport, even for structurally tagged failures. */
69
+ function batchDetails(error) {
70
+ const batch = error.batch;
71
+ if (!Schema.is(BatchErrorDetails)(batch)) {
72
+ return {};
73
+ }
74
+ return {
75
+ batch: {
76
+ indices: batch.indices,
77
+ ...(batch.fields === undefined ? {} : { fields: batch.fields }),
78
+ },
79
+ };
80
+ }
67
81
  export function toServerResponse(response) {
68
82
  return HttpServerResponse.jsonUnsafe(errorBodyForWire(response.body), {
69
83
  status: response.status,
@@ -323,6 +337,7 @@ export function expectedFailure(error, requestId) {
323
337
  detail: message,
324
338
  requestId,
325
339
  issues: [{ path: [], message }],
340
+ ...batchDetails(error),
326
341
  },
327
342
  };
328
343
  }
@@ -421,6 +436,7 @@ export function expectedFailure(error, requestId) {
421
436
  requestId,
422
437
  ...(typeof entity === "string" ? { entity } : {}),
423
438
  ...(typeof id === "string" ? { id } : {}),
439
+ ...batchDetails(error),
424
440
  },
425
441
  };
426
442
  }
@@ -434,6 +450,7 @@ export function expectedFailure(error, requestId) {
434
450
  requestId,
435
451
  ...(typeof entity === "string" ? { entity } : {}),
436
452
  ...(typeof property === "string" ? { property } : {}),
453
+ ...batchDetails(error),
437
454
  },
438
455
  };
439
456
  }
@@ -1,6 +1,7 @@
1
1
  import { BlobValue } from "../blob.js";
2
2
  import { Schema, SchemaTransformation } from "effect";
3
3
  import { wireStruct } from "../wire.js";
4
+ import { BatchErrorDetails } from "../errors.js";
4
5
  import { AGGREGATE_FILTER_OPERATORS, ENTITY_AGGREGATE_FILTER_OPERATORS, filterValuesIssue, MAXIMUM_FILTER_VALUES, } from "../aggregate-filters.js";
5
6
  import { isValueMatched, OntologyManifestWire } from "../manifest.js";
6
7
  import { entityQuerySchemas, queryLinks } from "../query.js";
@@ -510,14 +511,17 @@ export const ErrorSchemas = {
510
511
  notFound: errorBody("NotFound", 404, ["not_found"], {
511
512
  entity: Schema.optionalKey(Schema.String),
512
513
  id: Schema.optionalKey(Schema.String),
514
+ batch: Schema.optionalKey(BatchErrorDetails),
513
515
  }),
514
516
  conflict: errorBody("Conflict", 409, ["conflict"], {
515
517
  entity: Schema.optionalKey(Schema.String),
516
518
  property: Schema.optionalKey(Schema.String),
519
+ batch: Schema.optionalKey(BatchErrorDetails),
517
520
  }),
518
521
  unsupportedMediaType: errorBody("UnsupportedMediaType", 415, ["unsupported_media_type"]),
519
522
  unprocessable: errorBody("Unprocessable", 422, ["invalid_input", "action_rejected"], {
520
523
  issues: Schema.Array(ValidationIssue),
524
+ batch: Schema.optionalKey(BatchErrorDetails),
521
525
  }),
522
526
  rateLimited: errorBody("RateLimited", 429, ["rate_limited"]),
523
527
  internal: errorBody("InternalError", 500, ["internal_error"]),
@@ -6,11 +6,12 @@ import type { Principal } from "../runtime.js";
6
6
  import type { BlobProvider, TransferGrant } from "./provider.js";
7
7
  /**
8
8
  * The agent's temporary attachments: a file a member sends the app's agent is stored once, as a
9
- * `_blobs` revision in the ontology's private file storage, and belongs to that member, the chat it
10
- * arrived in and the agent session that first reads it. Nothing about it is an ontology record. It
11
- * expires 24 hours after receipt; within that time the member's agent may read it, discard it, or
12
- * promote it into a record through an action, which adopts the same stored object without copying
13
- * it. Every operation re-derives the owner, chat and session from the caller, never from input.
9
+ * `_blobs` revision in the ontology's private file storage. Group files belong to the chat's current
10
+ * members; private files belong to their sender and the agent session that first reads them.
11
+ * Nothing about it is an ontology record. It expires 24 hours after receipt; the agent may read it,
12
+ * discard it, or promote it into a record through an action, which adopts the same stored object
13
+ * without copying it. Every operation derives chat access from the caller and registry, never from
14
+ * tool input.
14
15
  */
15
16
  /** The largest file the agent takes: what Telegram's Bot API lets a bot download. */
16
17
  export declare const MAX_ATTACHMENT_BYTES: number;
@@ -25,8 +26,8 @@ export interface AttachmentCaller {
25
26
  /** The chat the attachment arrived in, such as `telegram:12345`. */
26
27
  readonly conversation: string;
27
28
  /**
28
- * The agent session the request comes from. The first session that reads an attachment owns it;
29
- * other sessions of the same chat, such as one after a reset, never see it.
29
+ * The agent session the request comes from. Private attachments stay bound to the first session
30
+ * that reads them. Group attachments are shared across authorized sessions in the same chat.
30
31
  */
31
32
  readonly sessionId?: string;
32
33
  }
@@ -9,11 +9,12 @@ import { authorizeBlobOperation } from "./authorization.js";
9
9
  import { ATTACHMENTS_TABLE } from "./tables.js";
10
10
  /**
11
11
  * The agent's temporary attachments: a file a member sends the app's agent is stored once, as a
12
- * `_blobs` revision in the ontology's private file storage, and belongs to that member, the chat it
13
- * arrived in and the agent session that first reads it. Nothing about it is an ontology record. It
14
- * expires 24 hours after receipt; within that time the member's agent may read it, discard it, or
15
- * promote it into a record through an action, which adopts the same stored object without copying
16
- * it. Every operation re-derives the owner, chat and session from the caller, never from input.
12
+ * `_blobs` revision in the ontology's private file storage. Group files belong to the chat's current
13
+ * members; private files belong to their sender and the agent session that first reads them.
14
+ * Nothing about it is an ontology record. It expires 24 hours after receipt; the agent may read it,
15
+ * discard it, or promote it into a record through an action, which adopts the same stored object
16
+ * without copying it. Every operation derives chat access from the caller and registry, never from
17
+ * tool input.
17
18
  */
18
19
  /** The largest file the agent takes: what Telegram's Bot API lets a bot download. */
19
20
  export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
@@ -102,9 +103,34 @@ export function attachmentRuntime(ontology, sql, provider) {
102
103
  const binding = provider.binding;
103
104
  const missing = (id) => new NotFound({ entity: "attachment", id, message: "Attachment not found." });
104
105
  const unavailable = (message) => new Conflict({ entity: "attachment", message });
106
+ /** The registry contains groups only. Neither a tool argument nor a caller's roles name a group. */
107
+ const accessScope = (caller) => Effect.gen(function* () {
108
+ const [member] = yield* sql.unsafe(`SELECT kind::text, active FROM "user" WHERE id = $1::uuid FOR SHARE`, [caller.principal.id]);
109
+ if (member?.kind !== "member" || !member.active) {
110
+ return "denied";
111
+ }
112
+ yield* authorizeBlobOperation(ontology, sql, caller.principal.id, []);
113
+ const separator = caller.conversation.indexOf(":");
114
+ const [chat] = yield* sql.unsafe(`SELECT id::text FROM "agent_chat"
115
+ WHERE provider = $1 AND external_id = $2 FOR SHARE`, [caller.conversation.slice(0, separator), caller.conversation.slice(separator + 1)]);
116
+ if (chat === undefined) {
117
+ return "private";
118
+ }
119
+ const [participant] = yield* sql.unsafe(`SELECT 1 FROM "agent_chat_members"
120
+ WHERE agent_chat_id = $1::uuid AND user_id = $2::uuid FOR SHARE`, [chat.id, caller.principal.id]);
121
+ if (participant !== undefined) {
122
+ return "group";
123
+ }
124
+ // App roles are authored by the workspace. Chat management grants its administrator access;
125
+ // re-read the roles here rather than trusting permissions cached on the principal.
126
+ const administrator = yield* authorizeBlobOperation(ontology, sql, caller.principal.id, [
127
+ "agent_chat:update",
128
+ ]).pipe(Effect.as(true), Effect.catchTag("PermissionDenied", () => Effect.succeed(false)));
129
+ return administrator ? "group" : "denied";
130
+ });
105
131
  /**
106
- * The caller's attachment, locked. Another member's, another chat's or another session's is not
107
- * found: it does not exist for this caller. The first session that loads it owns it.
132
+ * The caller's attachment, locked. Group access follows current chat membership. Private access
133
+ * requires the sender and their session. Source transfers always require the original sender.
108
134
  */
109
135
  const load = (id, caller,
110
136
  /** `source`: the webhook redelivering the file it stored, before or after a session read it. */
@@ -112,23 +138,37 @@ export function attachmentRuntime(ontology, sql, provider) {
112
138
  if (!/^[0-9a-f-]{36}$/i.test(id)) {
113
139
  return yield* missing(id);
114
140
  }
115
- const rows = yield* sql.unsafe(`${SELECT} WHERE a.id = $1::uuid AND a.owner_id = $2::uuid AND a.conversation = $3
116
- AND a.branch_id = $4 FOR UPDATE OF a, b`, [id, caller.principal.id, caller.conversation, binding.branchId]);
141
+ const access = yield* accessScope(caller);
142
+ if (access === "denied") {
143
+ return yield* missing(id);
144
+ }
145
+ const rows = yield* sql.unsafe(`${SELECT} WHERE a.id = $1::uuid AND (a.owner_id = $2::uuid OR $5) AND a.conversation = $3
146
+ AND a.branch_id = $4 FOR UPDATE OF a, b`, [
147
+ id,
148
+ caller.principal.id,
149
+ caller.conversation,
150
+ binding.branchId,
151
+ access === "group" && scope === "session",
152
+ ]);
117
153
  const row = rows[0];
118
154
  if (!row) {
119
155
  return yield* missing(id);
120
156
  }
121
- if (scope === "session" && row.session_id !== null && row.session_id !== caller.sessionId) {
157
+ if (access === "private" &&
158
+ scope === "session" &&
159
+ row.session_id !== null &&
160
+ row.session_id !== caller.sessionId) {
122
161
  return yield* missing(id);
123
162
  }
124
- if (scope === "session" && row.session_id === null && caller.sessionId !== undefined) {
163
+ if (access === "private" &&
164
+ scope === "session" &&
165
+ row.session_id === null &&
166
+ caller.sessionId !== undefined) {
125
167
  yield* sql.unsafe(`UPDATE "${ATTACHMENTS_TABLE}" SET session_id = $1 WHERE id = $2::uuid`, [
126
168
  caller.sessionId,
127
169
  row.id,
128
170
  ]);
129
171
  }
130
- // The member must still be active; a deactivated member's agent reads nothing.
131
- yield* authorizeBlobOperation(ontology, sql, caller.principal.id, [], caller.principal.execution);
132
172
  return row;
133
173
  });
134
174
  /**
@@ -203,7 +243,7 @@ export function attachmentRuntime(ontology, sql, provider) {
203
243
  input.size,
204
244
  input.sha256,
205
245
  ]);
206
- return yield* load(id, caller);
246
+ return yield* load(id, caller, "source");
207
247
  }));
208
248
  if (reserved.state !== "received" || reserved.expired) {
209
249
  return { attachment: view(reserved) };
@@ -232,7 +272,7 @@ export function attachmentRuntime(ontology, sql, provider) {
232
272
  });
233
273
  /** Completes the transfer, then verifies the stored bytes against the declared size and digest. */
234
274
  const complete = (id, caller) => Effect.gen(function* () {
235
- const row = yield* sql.withTransaction(load(id, caller));
275
+ const row = yield* sql.withTransaction(load(id, caller, "source"));
236
276
  if (row.state !== "received") {
237
277
  return READABLE.has(row.state) && !row.expired
238
278
  ? view(row)
@@ -267,16 +307,25 @@ export function attachmentRuntime(ontology, sql, provider) {
267
307
  return yield* sql.withTransaction(Effect.gen(function* () {
268
308
  yield* sql.unsafe(`UPDATE "${ATTACHMENTS_TABLE}" SET state = 'ready'
269
309
  WHERE id = $1::uuid AND state = 'received'`, [row.id]);
270
- return view(yield* load(row.id, caller));
310
+ return view(yield* load(row.id, caller, "source"));
271
311
  }));
272
312
  });
273
- /** The caller's attachments in this chat and session that are still readable, newest first. */
313
+ /** Accessible attachments in this chat that are still readable, newest first. */
274
314
  const list = (caller) => sql.withTransaction(Effect.gen(function* () {
275
- yield* authorizeBlobOperation(ontology, sql, caller.principal.id, [], caller.principal.execution);
276
- const rows = yield* sql.unsafe(`${SELECT} WHERE a.owner_id = $1::uuid AND a.conversation = $2 AND a.branch_id = $3
315
+ const access = yield* accessScope(caller);
316
+ if (access === "denied") {
317
+ return [];
318
+ }
319
+ const rows = yield* sql.unsafe(`${SELECT} WHERE (a.owner_id = $1::uuid OR $5) AND a.conversation = $2 AND a.branch_id = $3
277
320
  AND a.state IN ('ready', 'promoted') AND a.expires_at > now()
278
- AND (a.session_id IS NULL OR a.session_id = $4)
279
- ORDER BY a.created_at DESC LIMIT ${MAX_LIVE_ATTACHMENTS}`, [caller.principal.id, caller.conversation, binding.branchId, caller.sessionId ?? null]);
321
+ AND ($5 OR a.session_id IS NULL OR a.session_id = $4)
322
+ ORDER BY a.created_at DESC LIMIT ${MAX_LIVE_ATTACHMENTS}`, [
323
+ caller.principal.id,
324
+ caller.conversation,
325
+ binding.branchId,
326
+ caller.sessionId ?? null,
327
+ access === "group",
328
+ ]);
280
329
  return rows.map(view);
281
330
  }));
282
331
  /**
@@ -351,6 +400,7 @@ export function attachmentRuntime(ontology, sql, provider) {
351
400
  const qualified = `${target.entity}.${target.action}`;
352
401
  const prepared = yield* sql.withTransaction(Effect.gen(function* () {
353
402
  const row = yield* load(id, caller);
403
+ yield* authorizeBlobOperation(ontology, sql, caller.principal.id, found.permissions, caller.principal.execution);
354
404
  if (row.state === "promoted") {
355
405
  return { kind: "done", row };
356
406
  }
@@ -365,11 +415,21 @@ export function attachmentRuntime(ontology, sql, provider) {
365
415
  `${row.content_type}, ${row.size} bytes.`,
366
416
  });
367
417
  }
368
- yield* authorizeBlobOperation(ontology, sql, caller.principal.id, found.permissions, caller.principal.execution);
369
418
  const [earlier] = yield* sql.unsafe(`SELECT id::text, promotion FROM "${ATTACHMENTS_TABLE}"
370
419
  WHERE sha256 = $1 AND branch_id = $2 AND state = 'promoted' AND id <> $3::uuid
371
420
  AND promotion->>'action' = $4 AND promotion->>'deduplicated_from' IS NULL
372
- ORDER BY created_at LIMIT 1`, [row.sha256, binding.branchId, row.id, qualified]);
421
+ AND conversation = $5 AND (owner_id = $6::uuid OR $7)
422
+ AND ($7 OR session_id IS NULL OR session_id = $8)
423
+ ORDER BY created_at LIMIT 1`, [
424
+ row.sha256,
425
+ binding.branchId,
426
+ row.id,
427
+ qualified,
428
+ caller.conversation,
429
+ caller.principal.id,
430
+ (yield* accessScope(caller)) === "group",
431
+ caller.sessionId ?? null,
432
+ ]);
373
433
  if (earlier) {
374
434
  const promotion = {
375
435
  ...earlier.promotion,