zigmini 1.0.0-beta.3 → 1.1.0-beta.1

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 (138) hide show
  1. package/README.ko.md +27 -2
  2. package/README.md +27 -2
  3. package/assets/agent/gateway-request-rules.md +1 -1
  4. package/assets/agent/gateway-rules.md +6 -1
  5. package/assets/android/zigmini.apk +0 -0
  6. package/assets/android/zigmini.apk.json +3 -3
  7. package/assets/build.json +1 -1
  8. package/assets/document-reader/document-reader +0 -0
  9. package/assets/document-reader/host-selector +0 -0
  10. package/assets/document-reader/manifest.json +27 -0
  11. package/docs/manual.ko.md +114 -10
  12. package/docs/manual.md +117 -11
  13. package/host/document-reader/DocumentRead.swift +370 -0
  14. package/host/document-reader/HostSelector.swift +192 -0
  15. package/host/document-reader/Protocol.swift +647 -0
  16. package/host/document-reader/main.swift +22 -0
  17. package/host/omo-bridge/agent-aside-tool.ts +119 -0
  18. package/host/omo-bridge/agent-aside.ts +522 -0
  19. package/host/omo-bridge/agent-collaboration-files.ts +282 -0
  20. package/host/omo-bridge/agent-collaboration.ts +385 -0
  21. package/host/omo-bridge/agent-control.ts +400 -28
  22. package/host/omo-bridge/agent-execution.sb +42 -0
  23. package/host/omo-bridge/agent-execution.ts +158 -0
  24. package/host/omo-bridge/agent-tool-adapters.ts +388 -0
  25. package/host/omo-bridge/agent-tool-permissions.ts +96 -0
  26. package/host/omo-bridge/aside-tabs.ts +41 -0
  27. package/host/omo-bridge/attachment-read.ts +100 -0
  28. package/host/omo-bridge/bridge.ts +10 -0
  29. package/host/omo-bridge/managed-call.ts +46 -0
  30. package/host/omo-bridge/task-control.ts +39 -5
  31. package/host/omo-check/patches/attachments-293/README.md +138 -0
  32. package/host/omo-check/patches/attachments-293/document-content.js +234 -0
  33. package/host/omo-check/patches/attachments-293/document-content.types.txt +91 -0
  34. package/host/omo-check/patches/attachments-293/omo-5.1.51-documents.patch +831 -0
  35. package/host/omo-check/patches/attachments-293/omo-5.1.51-gateway-port.manifest.json +471 -0
  36. package/host/omo-check/patches/omo-5.1.51-agent-tool-authority.patch +471 -0
  37. package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +28 -6
  38. package/host/omo-check/patches/omo-5.1.51-memory-receipts.patch +5 -0
  39. package/package.json +5 -3
  40. package/src/agent/api.ts +345 -20
  41. package/src/agent/artifacts.ts +196 -0
  42. package/src/agent/aside-target-routes.ts +369 -0
  43. package/src/agent/aside-targets.ts +215 -0
  44. package/src/agent/collaboration-admission.ts +47 -0
  45. package/src/agent/collaboration-read.ts +117 -0
  46. package/src/agent/collaboration-session.ts +196 -0
  47. package/src/agent/collaboration-types.ts +258 -0
  48. package/src/agent/collaboration.ts +399 -0
  49. package/src/agent/definition.ts +65 -3
  50. package/src/agent/fields.ts +9 -3
  51. package/src/agent/file-core.ts +325 -0
  52. package/src/agent/files.ts +87 -8
  53. package/src/agent/list.ts +3 -3
  54. package/src/agent/managed-call.ts +144 -0
  55. package/src/agent/tool-catalog.ts +184 -0
  56. package/src/agent/tool-delivery.ts +359 -0
  57. package/src/agent/tool-permission-types.ts +81 -0
  58. package/src/agent/tool-permissions.ts +1366 -0
  59. package/src/attachments/api.ts +161 -0
  60. package/src/attachments/document-reader.ts +325 -0
  61. package/src/attachments/host-selector.ts +410 -0
  62. package/src/attachments/managed-binding.ts +83 -0
  63. package/src/attachments/prepare.ts +214 -0
  64. package/src/attachments/selector-port.ts +28 -0
  65. package/src/attachments/service.ts +729 -0
  66. package/src/attachments/store.ts +698 -0
  67. package/src/attachments/types.ts +123 -0
  68. package/src/cli/doctor.ts +30 -10
  69. package/src/decisions.ts +109 -10
  70. package/src/engine/apply-patch.ts +21 -0
  71. package/src/engine/compat.json +2 -1
  72. package/src/engine/install.ts +45 -4
  73. package/src/engine/sandbox-cell.ts +2 -0
  74. package/src/gc.ts +33 -225
  75. package/src/housekeeping/api.ts +163 -0
  76. package/src/housekeeping/audit.ts +61 -0
  77. package/src/housekeeping/collectors/agent-collaboration.ts +33 -0
  78. package/src/housekeeping/collectors/agent-drafts.ts +43 -0
  79. package/src/housekeeping/collectors/agent-file-trash.ts +36 -0
  80. package/src/housekeeping/collectors/agent-trash.ts +28 -0
  81. package/src/housekeeping/collectors/attachment-cleanup.ts +35 -0
  82. package/src/housekeeping/collectors/db-backup.ts +25 -0
  83. package/src/housekeeping/collectors/docker-build-cache.ts +29 -0
  84. package/src/housekeeping/collectors/docker-image.ts +135 -0
  85. package/src/housekeeping/collectors/omo-version.ts +32 -0
  86. package/src/housekeeping/collectors/orphan-test-process.ts +205 -0
  87. package/src/housekeeping/collectors/reconcile-work.ts +55 -0
  88. package/src/housekeeping/collectors/rpc-daemon.ts +39 -0
  89. package/src/housekeeping/collectors/shared.ts +149 -0
  90. package/src/housekeeping/collectors/temp-dir.ts +28 -0
  91. package/src/housekeeping/collectors/update-file.ts +25 -0
  92. package/src/housekeeping/collectors/worktree.ts +193 -0
  93. package/src/housekeeping/engine.ts +125 -0
  94. package/src/housekeeping/reconcile/work.ts +130 -0
  95. package/src/housekeeping/registry.ts +63 -0
  96. package/src/housekeeping/trash.ts +59 -0
  97. package/src/housekeeping/types.ts +31 -0
  98. package/src/hub.ts +1095 -41
  99. package/src/main.ts +228 -30
  100. package/src/message-references.ts +14 -5
  101. package/src/profile-prompt.ts +13 -1
  102. package/src/protocol.ts +41 -6
  103. package/src/security-allow.ts +79 -2
  104. package/src/security-check.ts +1 -1
  105. package/src/store.ts +1946 -23
  106. package/src/test-crash.ts +20 -0
  107. package/src/work-watch.ts +55 -7
  108. package/src/work.ts +192 -3
  109. package/web/DESIGN.md +33 -0
  110. package/web/agents.d.ts +189 -12
  111. package/web/agents.js +898 -39
  112. package/web/app.js +2634 -210
  113. package/web/attachments.d.ts +91 -0
  114. package/web/attachments.js +422 -0
  115. package/web/collaboration.d.ts +87 -0
  116. package/web/collaboration.js +290 -0
  117. package/web/decisions.d.ts +39 -2
  118. package/web/decisions.js +354 -2
  119. package/web/gc.d.ts +26 -5
  120. package/web/gc.js +113 -13
  121. package/web/i18n/agents.d.ts +4 -0
  122. package/web/i18n/agents.js +200 -8
  123. package/web/i18n/attachments.js +78 -0
  124. package/web/i18n/decisions.d.ts +4 -0
  125. package/web/i18n/decisions.js +68 -0
  126. package/web/i18n/gc.d.ts +4 -0
  127. package/web/i18n/gc.js +184 -2
  128. package/web/i18n/main.js +80 -8
  129. package/web/i18n/tour.d.ts +4 -0
  130. package/web/i18n/tour.js +37 -0
  131. package/web/index.html +20 -3
  132. package/web/profile.d.ts +2 -2
  133. package/web/profile.js +11 -6
  134. package/web/style.css +342 -21
  135. package/web/tooltip.d.ts +2 -0
  136. package/web/tooltip.js +12 -0
  137. package/web/tour.d.ts +57 -0
  138. package/web/tour.js +450 -0
@@ -0,0 +1,698 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { createHash } from "node:crypto";
3
+ import type { SecurityReceipt } from "../security-allow.ts";
4
+ import type { StoredEvent } from "../store.ts";
5
+ import {
6
+ AttachmentStoreError, type AttachmentAccepted, type AttachmentAdmission, type AttachmentCAS, type AttachmentCurrent,
7
+ type AttachmentEncoding, type AttachmentFormat, type AttachmentGate, type AttachmentJob, type AttachmentManifest,
8
+ type AttachmentMetadata, type AttachmentPreparation, type AttachmentScope, type AttachmentSelection,
9
+ type AttachmentState, type SelectedAttachmentExport,
10
+ } from "./types.ts";
11
+
12
+ const MAX_BYTES = 10 * 1024 * 1024;
13
+ const SHA = /^[a-f0-9]{64}$/;
14
+ const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
15
+ const positive = (n: number) => Number.isSafeInteger(n) && n > 0;
16
+ function fail(code: ConstructorParameters<typeof AttachmentStoreError>[0], status = 409): never { throw new AttachmentStoreError(code, status); }
17
+ const object = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
18
+ function canonical(value: unknown): string {
19
+ if (value === null || typeof value === "boolean" || typeof value === "string") return JSON.stringify(value);
20
+ if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
21
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
22
+ if (object(value) && Object.getPrototypeOf(value) === Object.prototype) {
23
+ return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
24
+ }
25
+ return fail("invalid_attachment_input", 400);
26
+ }
27
+ const digest = (value: unknown): string => createHash("sha256").update(canonical(value)).digest("hex");
28
+ export function attachmentRequestDigest(admission: Omit<AttachmentAdmission, "requestDigest">, data: Readonly<Record<string, unknown>>): string {
29
+ return digest({ admission, data });
30
+ }
31
+ function validateScope(s: AttachmentScope): void {
32
+ if (!s || typeof s.ownerPrincipal !== "string" || !s.ownerPrincipal || !UUID.test(s.draftId) ||
33
+ typeof s.convId !== "string" || !s.convId || !positive(s.convEpoch) || !s.recipient ||
34
+ !["main", "agent"].includes(s.recipient.kind) || typeof s.recipient.id !== "string" || !s.recipient.id ||
35
+ !positive(s.recipient.epoch)) fail("invalid_attachment_input", 400);
36
+ }
37
+ interface Row {
38
+ readonly id: string; readonly owner_principal: string; readonly draft_id: string; readonly conv_id: string;
39
+ readonly conv_epoch: number; readonly recipient_kind: "main" | "agent"; readonly recipient_id: string; readonly recipient_epoch: number;
40
+ readonly original_name: string; readonly format: AttachmentFormat; readonly bytes: number; readonly sha256: string | null;
41
+ readonly state: AttachmentState; readonly revision: number; readonly job_generation: string;
42
+ readonly preparation_revision: number; readonly prepared_manifest_json: string | null; readonly error_code: string | null;
43
+ readonly preparation_encoding: AttachmentEncoding | null;
44
+ }
45
+ interface Grant {
46
+ readonly id: string; readonly attachment_id: string; readonly conv_id: string; readonly recipient_id: string;
47
+ readonly message_seq: number; readonly revision: number; readonly revoked_at: string | null;
48
+ readonly conv_epoch: number; readonly recipient_epoch: number; readonly recipient_authority_epoch: number | null;
49
+ }
50
+ const rowScope = (r: Row): AttachmentScope => ({ ownerPrincipal: r.owner_principal, draftId: r.draft_id,
51
+ convId: r.conv_id, convEpoch: r.conv_epoch, recipient: { kind: r.recipient_kind, id: r.recipient_id, epoch: r.recipient_epoch } });
52
+ const job = (r: Row): AttachmentJob => ({ attachmentId: r.id, state: r.state, revision: r.revision,
53
+ jobGeneration: r.job_generation, preparationRevision: r.preparation_revision });
54
+ const preparation = (r: Row): AttachmentPreparation => {
55
+ if (!r.prepared_manifest_json) return fail("attachment_not_ready");
56
+ return JSON.parse(r.prepared_manifest_json);
57
+ };
58
+
59
+ /** Owns no connection. The parent Store installs this additive schema and supplies its existing acceptance. */
60
+ export class AttachmentStore {
61
+ constructor(private readonly db: Database, private readonly accept: (conv: string, data: unknown, receipt?: (ref: SecurityReceipt) => void) => StoredEvent) {}
62
+
63
+ migrate(): void {
64
+ this.db.transaction(() => this.db.exec(`
65
+ CREATE TABLE IF NOT EXISTS attachments (
66
+ id TEXT PRIMARY KEY, owner_principal TEXT NOT NULL, draft_id TEXT NOT NULL,
67
+ conv_id TEXT NOT NULL REFERENCES conversations(id), recipient_kind TEXT NOT NULL CHECK(recipient_kind IN ('main','agent')),
68
+ recipient_id TEXT NOT NULL, conv_epoch INTEGER NOT NULL CHECK(conv_epoch>0), recipient_epoch INTEGER NOT NULL CHECK(recipient_epoch>0),
69
+ original_name TEXT NOT NULL, format TEXT NOT NULL CHECK(format IN ('html','md','txt','pdf')),
70
+ bytes INTEGER NOT NULL CHECK(bytes>0 AND bytes<=10485760), sha256 TEXT,
71
+ state TEXT NOT NULL CHECK(state IN ('snapshotting','sealed','preparing','ready','bound','discarded','error','revoked')),
72
+ revision INTEGER NOT NULL CHECK(revision>0), source_identity_json TEXT NOT NULL, created_at TEXT NOT NULL,
73
+ sealed_at TEXT, error_code TEXT, preparation_revision INTEGER NOT NULL DEFAULT 0,
74
+ prepared_manifest_json TEXT, job_generation TEXT NOT NULL,
75
+ preparation_encoding TEXT CHECK(preparation_encoding IN ('utf-8','utf-16le','utf-16be','euc-kr','cp949')),
76
+ CHECK(sha256 IS NULL OR (length(sha256)=64 AND sha256 NOT GLOB '*[^a-f0-9]*'))
77
+ );
78
+ CREATE INDEX IF NOT EXISTS attachment_staging ON attachments(owner_principal,state);
79
+ CREATE TABLE IF NOT EXISTS attachment_selectors (hash TEXT PRIMARY KEY);
80
+ CREATE TABLE IF NOT EXISTS attachment_operations (
81
+ principal TEXT NOT NULL, draft_id TEXT NOT NULL, operation_id TEXT NOT NULL, request_digest TEXT NOT NULL,
82
+ attachment_id TEXT NOT NULL REFERENCES attachments(id), receipt_json TEXT NOT NULL,
83
+ PRIMARY KEY(principal,draft_id,operation_id)
84
+ );
85
+ CREATE TABLE IF NOT EXISTS attachment_grants (
86
+ id TEXT PRIMARY KEY, attachment_id TEXT NOT NULL REFERENCES attachments(id), conv_id TEXT NOT NULL,
87
+ recipient_id TEXT NOT NULL, message_seq INTEGER NOT NULL REFERENCES events(seq), revision INTEGER NOT NULL CHECK(revision>0),
88
+ revoked_at TEXT, conv_epoch INTEGER NOT NULL, recipient_epoch INTEGER NOT NULL, recipient_authority_epoch INTEGER,
89
+ UNIQUE(attachment_id,message_seq)
90
+ );
91
+ CREATE TABLE IF NOT EXISTS attachment_submissions (
92
+ conv_id TEXT NOT NULL, submission_id TEXT NOT NULL, request_digest TEXT NOT NULL,
93
+ message_seq INTEGER NOT NULL REFERENCES events(seq), receipt_json TEXT NOT NULL,
94
+ PRIMARY KEY(conv_id,submission_id)
95
+ );
96
+ CREATE TABLE IF NOT EXISTS attachment_tombstones (
97
+ id TEXT PRIMARY KEY REFERENCES attachments(id), digest TEXT, submission_keys_json TEXT NOT NULL,
98
+ reason TEXT NOT NULL, created_at TEXT NOT NULL
99
+ );
100
+ CREATE TABLE IF NOT EXISTS attachment_recipient_epochs (recipient_id TEXT PRIMARY KEY, epoch INTEGER NOT NULL CHECK(epoch>1));
101
+ CREATE TABLE IF NOT EXISTS attachment_read_leases (
102
+ id TEXT PRIMARY KEY, attachment_id TEXT NOT NULL REFERENCES attachments(id), receipt_json TEXT NOT NULL
103
+ );
104
+ CREATE TRIGGER IF NOT EXISTS attachment_immutable_original
105
+ BEFORE UPDATE ON attachments WHEN OLD.sha256 IS NOT NULL AND (
106
+ NEW.sha256 IS NOT OLD.sha256 OR NEW.bytes != OLD.bytes OR NEW.original_name != OLD.original_name OR
107
+ NEW.format != OLD.format OR NEW.source_identity_json != OLD.source_identity_json)
108
+ BEGIN SELECT RAISE(ABORT,'attachment_immutable_original'); END;
109
+ CREATE TRIGGER IF NOT EXISTS attachment_immutable_scope
110
+ BEFORE UPDATE ON attachments WHEN NEW.owner_principal != OLD.owner_principal OR NEW.draft_id != OLD.draft_id OR
111
+ NEW.conv_id != OLD.conv_id OR NEW.conv_epoch != OLD.conv_epoch OR NEW.recipient_kind != OLD.recipient_kind OR
112
+ NEW.recipient_id != OLD.recipient_id OR NEW.recipient_epoch != OLD.recipient_epoch
113
+ BEGIN SELECT RAISE(ABORT,'attachment_immutable_scope'); END;
114
+ CREATE TRIGGER IF NOT EXISTS attachment_immutable_preparation
115
+ BEFORE UPDATE ON attachments WHEN EXISTS (SELECT 1 FROM attachment_grants WHERE attachment_id=OLD.id) AND
116
+ (NEW.preparation_revision != OLD.preparation_revision OR NEW.prepared_manifest_json IS NOT OLD.prepared_manifest_json OR
117
+ NEW.preparation_encoding IS NOT OLD.preparation_encoding)
118
+ BEGIN SELECT RAISE(ABORT,'attachment_immutable_preparation'); END;
119
+ `)).immediate();
120
+ const columns = this.db.query<{ name: string }, []>("PRAGMA table_info(attachment_tombstones)").all().map(column => column.name);
121
+ if (!columns.includes("trashed_at")) this.db.exec("ALTER TABLE attachment_tombstones ADD COLUMN trashed_at TEXT");
122
+ }
123
+
124
+ private row(id: string): Row {
125
+ if (!UUID.test(id)) return fail("invalid_attachment_input", 400);
126
+ const row = this.db.query<Row, [string]>(`SELECT id,owner_principal,draft_id,conv_id,conv_epoch,recipient_kind,recipient_id,
127
+ recipient_epoch,original_name,format,bytes,sha256,state,revision,job_generation,preparation_revision,
128
+ prepared_manifest_json,error_code,preparation_encoding FROM attachments WHERE id=?`).get(id);
129
+ if (!row) return fail("attachment_not_found", 404);
130
+ return row;
131
+ }
132
+ private live(id: string): void {
133
+ if (this.db.query("SELECT id FROM attachment_tombstones WHERE id=?").get(id)) fail("attachment_tombstoned", 410);
134
+ }
135
+ private current(scope: AttachmentScope, gate: AttachmentGate, owner = true): AttachmentCurrent {
136
+ validateScope(scope);
137
+ const conv = this.db.query<{ agent_id: string | null }, [string]>("SELECT agent_id FROM conversations WHERE id=?").get(scope.convId);
138
+ if (!conv || (scope.recipient.kind === "agent" ? conv.agent_id !== scope.recipient.id : conv.agent_id !== null)) return fail("attachment_scope_mismatch");
139
+ const current = gate.current(this.db, scope);
140
+ if (!current || !positive(current.convEpoch) || !current.recipient || !positive(current.recipient.epoch) ||
141
+ typeof current.ownerActive !== "boolean") return fail("attachment_provider_invalid", 503);
142
+ if (owner && !current.ownerActive) fail("attachment_owner_inactive", 403);
143
+ if (current.convEpoch !== scope.convEpoch || current.recipient.id !== scope.recipient.id ||
144
+ current.recipient.kind !== scope.recipient.kind || current.recipient.epoch !== scope.recipient.epoch) fail("attachment_scope_mismatch");
145
+ return current;
146
+ }
147
+ private modelAccess(current: AttachmentCurrent, epoch?: number | null): void {
148
+ if (current.authority) {
149
+ if (!positive(current.authority.epoch) || !positive(current.authority.policyRevision) ||
150
+ !["open", "blocked"].includes(current.authority.accessState)) fail("attachment_provider_invalid", 503);
151
+ if (current.authority.accessState === "blocked") fail("agent_access_blocked", 403);
152
+ }
153
+ if (epoch !== undefined && epoch !== (current.authority?.epoch ?? null)) fail("authority_epoch_mismatch");
154
+ }
155
+ private owns(r: Row, scope: AttachmentScope, draft = true): void {
156
+ if (r.owner_principal !== scope.ownerPrincipal || (draft && r.draft_id !== scope.draftId) ||
157
+ r.conv_id !== scope.convId || r.conv_epoch !== scope.convEpoch || r.recipient_kind !== scope.recipient.kind ||
158
+ r.recipient_id !== scope.recipient.id || r.recipient_epoch !== scope.recipient.epoch) fail("attachment_scope_mismatch");
159
+ }
160
+ private original(r: Row, gate: AttachmentGate): void {
161
+ const original = gate.original(this.db, r.id);
162
+ if (!original || original.bytes !== r.bytes || original.sha256 !== r.sha256) fail("attachment_original_unavailable", 410);
163
+ }
164
+ private cas(r: Row, cas: AttachmentCAS, states: readonly AttachmentState[]): void {
165
+ if (!positive(cas.expectedRevision) || !UUID.test(cas.jobGeneration)) fail("invalid_attachment_input", 400);
166
+ this.live(r.id);
167
+ if (r.revision !== cas.expectedRevision || r.job_generation !== cas.jobGeneration || !states.includes(r.state)) fail("attachment_cas_conflict");
168
+ }
169
+ private operation(scope: AttachmentScope, operationId: string, requestDigest: string): AttachmentJob | null {
170
+ const old = this.db.query<{ request_digest: string; receipt_json: string }, [string, string, string]>(
171
+ "SELECT request_digest,receipt_json FROM attachment_operations WHERE principal=? AND draft_id=? AND operation_id=?")
172
+ .get(scope.ownerPrincipal, scope.draftId, operationId);
173
+ if (!old) return null;
174
+ if (old.request_digest !== requestDigest) return fail("operation_conflict");
175
+ return JSON.parse(old.receipt_json);
176
+ }
177
+ private saveOperation(scope: AttachmentScope, operationId: string, requestDigest: string, result: AttachmentJob): AttachmentJob {
178
+ this.db.query("INSERT INTO attachment_operations VALUES (?,?,?,?,?,?)").run(scope.ownerPrincipal, scope.draftId,
179
+ operationId, requestDigest, result.attachmentId, JSON.stringify(result));
180
+ return result;
181
+ }
182
+ select(input: AttachmentSelection, gate: AttachmentGate): AttachmentJob {
183
+ validateScope(input.scope);
184
+ if (!UUID.test(input.operationId) || !UUID.test(input.jobGeneration) || typeof input.name !== "string" || !input.name ||
185
+ !["html", "md", "txt", "pdf"].includes(input.format) || !positive(input.bytes) || input.bytes > MAX_BYTES ||
186
+ !object(input.entryIdentity) || !object(input.sourceIdentity)) fail("invalid_attachment_input", 400);
187
+ const requestDigest = digest({ kind: "select", scope: input.scope, entryIdentity: input.entryIdentity,
188
+ sourceIdentity: input.sourceIdentity, name: input.name, format: input.format, bytes: input.bytes });
189
+ return this.db.transaction(() => {
190
+ this.current(input.scope, gate);
191
+ const old = this.operation(input.scope, input.operationId, requestDigest);
192
+ if (old) return old;
193
+ const quota = this.db.query<{ count: number; bytes: number }, [string]>(
194
+ "SELECT count(*) count,coalesce(sum(bytes),0) bytes FROM attachments WHERE owner_principal=? AND state NOT IN ('bound','revoked','discarded') AND id NOT IN (SELECT id FROM attachment_tombstones WHERE trashed_at IS NOT NULL)")
195
+ .get(input.scope.ownerPrincipal);
196
+ if (!quota || quota.count >= 16 || quota.bytes + input.bytes > 64 * 1024 * 1024) fail("staging_quota_exceeded", 429);
197
+ const id = crypto.randomUUID(), s = input.scope;
198
+ this.db.query(`INSERT INTO attachments (id,owner_principal,draft_id,conv_id,recipient_kind,recipient_id,conv_epoch,recipient_epoch,
199
+ original_name,format,bytes,state,revision,source_identity_json,created_at,job_generation) VALUES (?,?,?,?,?,?,?,?,?,?,?,'snapshotting',1,?,?,?)`)
200
+ .run(id, s.ownerPrincipal, s.draftId, s.convId, s.recipient.kind, s.recipient.id, s.convEpoch, s.recipient.epoch,
201
+ input.name, input.format, input.bytes, canonical(input.sourceIdentity), new Date().toISOString(), input.jobGeneration);
202
+ return this.saveOperation(s, input.operationId, requestDigest, job(this.row(id)));
203
+ }).immediate();
204
+ }
205
+ seal(id: string, cas: AttachmentCAS, original: { readonly bytes: number; readonly sha256: string }, gate: AttachmentGate): AttachmentJob {
206
+ if (!SHA.test(original.sha256) || !positive(original.bytes) || original.bytes > MAX_BYTES) fail("invalid_attachment_input", 400);
207
+ return this.db.transaction(() => {
208
+ const r = this.row(id);
209
+ this.cas(r, cas, ["snapshotting"]);
210
+ if (r.bytes !== original.bytes) fail("attachment_manifest_conflict");
211
+ this.original({ ...r, sha256: original.sha256 }, gate);
212
+ this.db.query("UPDATE attachments SET sha256=?,state='sealed',revision=revision+1,sealed_at=? WHERE id=?")
213
+ .run(original.sha256, new Date().toISOString(), id);
214
+ return job(this.row(id));
215
+ }).immediate();
216
+ }
217
+ prepare(scope: AttachmentScope, id: string,
218
+ input: { readonly expectedRevision: number; readonly operationId: string; readonly jobGeneration: string; readonly encoding?: AttachmentEncoding },
219
+ gate: AttachmentGate): AttachmentJob {
220
+ if (!UUID.test(input.operationId) || !UUID.test(input.jobGeneration) || !positive(input.expectedRevision) ||
221
+ (input.encoding !== undefined && !["utf-8", "utf-16le", "utf-16be", "euc-kr", "cp949"].includes(input.encoding))) fail("invalid_attachment_input", 400);
222
+ const requestDigest = digest({ kind: "prepare", id, scope, expectedRevision: input.expectedRevision, encoding: input.encoding ?? null });
223
+ return this.db.transaction(() => {
224
+ this.current(scope, gate);
225
+ const r = this.row(id);
226
+ this.owns(r, scope);
227
+ const old = this.operation(scope, input.operationId, requestDigest);
228
+ if (old) return old;
229
+ this.live(id);
230
+ if (this.db.query("SELECT id FROM attachment_grants WHERE attachment_id=?").get(id)) fail("attachment_already_bound");
231
+ if (r.revision !== input.expectedRevision || !["sealed", "preparing", "ready", "error"].includes(r.state) || !r.sha256) fail("attachment_cas_conflict");
232
+ this.original(r, gate);
233
+ this.db.query(`UPDATE attachments SET state='preparing',revision=revision+1,preparation_revision=preparation_revision+1,
234
+ prepared_manifest_json=NULL,error_code=NULL,job_generation=?,preparation_encoding=? WHERE id=?`)
235
+ .run(input.jobGeneration, input.encoding ?? null, id);
236
+ return this.saveOperation(scope, input.operationId, requestDigest, job(this.row(id)));
237
+ }).immediate();
238
+ }
239
+ /** A private worker descriptor, not owner status or model metadata. Recovery keeps the selected decoding option. */
240
+ preparationWork(id: string, cas: AttachmentCAS) {
241
+ return this.db.transaction(() => {
242
+ const r = this.row(id);
243
+ this.cas(r, cas, ["sealed", "preparing"]);
244
+ if (!r.sha256) fail("attachment_not_ready");
245
+ return { ...job(r), originalSha256: r.sha256, format: r.format, bytes: r.bytes, encoding: r.preparation_encoding };
246
+ }).immediate();
247
+ }
248
+ publish(id: string, cas: AttachmentCAS, manifest: AttachmentPreparation): AttachmentJob {
249
+ if (!SHA.test(manifest.originalSha256) || !SHA.test(manifest.representationSha256) || !SHA.test(manifest.inspectedTextSha256) ||
250
+ !positive(manifest.preparationRevision) || !["text", "native-document", "pages", "ocr"].includes(manifest.readMode) ||
251
+ (manifest.pageCount !== undefined && (!positive(manifest.pageCount) || manifest.pageCount > 300))) fail("invalid_attachment_input", 400);
252
+ return this.db.transaction(() => {
253
+ const r = this.row(id);
254
+ this.cas(r, cas, ["preparing"]);
255
+ if (r.preparation_revision !== manifest.preparationRevision || r.sha256 !== manifest.originalSha256) fail("attachment_manifest_conflict");
256
+ this.db.query("UPDATE attachments SET state='ready',revision=revision+1,prepared_manifest_json=?,error_code=NULL WHERE id=?")
257
+ .run(canonical(manifest), id);
258
+ return job(this.row(id));
259
+ }).immediate();
260
+ }
261
+ failJob(id: string, cas: AttachmentCAS, errorCode: string): AttachmentJob {
262
+ if (!/^[a-z][a-z0-9_]{0,79}$/.test(errorCode)) fail("invalid_attachment_input", 400);
263
+ return this.db.transaction(() => {
264
+ const r = this.row(id);
265
+ this.cas(r, cas, ["snapshotting", "sealed", "preparing"]);
266
+ this.db.query("UPDATE attachments SET state='error',revision=revision+1,error_code=? WHERE id=?").run(errorCode, id);
267
+ return job(this.row(id));
268
+ }).immediate();
269
+ }
270
+ discard(scope: AttachmentScope, id: string, expectedRevision: number, gate: AttachmentGate): AttachmentJob {
271
+ return this.db.transaction(() => {
272
+ this.current(scope, gate);
273
+ const r = this.row(id);
274
+ this.owns(r, scope);
275
+ this.live(id);
276
+ if (this.db.query("SELECT id FROM attachment_grants WHERE attachment_id=?").get(id)) fail("attachment_already_bound");
277
+ if (r.revision !== expectedRevision || r.state === "discarded") fail("attachment_cas_conflict");
278
+ this.db.query("UPDATE attachments SET state='discarded',revision=revision+1,job_generation=? WHERE id=?").run(crypto.randomUUID(), id);
279
+ return job(this.row(id));
280
+ }).immediate();
281
+ }
282
+ status(scope: AttachmentScope, id: string, gate: AttachmentGate) {
283
+ return this.db.transaction(() => {
284
+ this.current(scope, gate);
285
+ const r = this.row(id);
286
+ this.owns(r, scope, !["bound", "revoked"].includes(r.state));
287
+ const manifest = r.prepared_manifest_json ? preparation(r) : null;
288
+ return { id: r.id, name: r.original_name, format: r.format, bytes: r.bytes, sha256: r.sha256, state: r.state, revision: r.revision,
289
+ preparation: { revision: r.preparation_revision, state: manifest ? "ready" : r.error_code === "encoding_required" ? "encoding_required" : r.state === "error" ? "error" : "pending" },
290
+ readMode: manifest?.readMode ?? null, ...(r.error_code ? { error: r.error_code } : {}) };
291
+ }).immediate();
292
+ }
293
+ private grant(id: string): Grant {
294
+ const grant = this.db.query<Grant, [string]>("SELECT * FROM attachment_grants WHERE id=?").get(id);
295
+ if (!grant) return fail("attachment_reference_unavailable", 410);
296
+ return grant;
297
+ }
298
+ /** A grant with no row is treated as revoked, so a client never shows it as active. */
299
+ private grantRevoked(grantId: string): boolean {
300
+ const row = this.db.query<{ revoked_at: string | null }, [string]>("SELECT revoked_at FROM attachment_grants WHERE id=?").get(grantId);
301
+ return !row || row.revoked_at !== null;
302
+ }
303
+ /** A copy of a stored message for clients, with each attachment's own grant state. The stored message is not changed. */
304
+ withGrantState(data: unknown): unknown {
305
+ if (!object(data)) return data;
306
+ const mark = (items: unknown) => Array.isArray(items)
307
+ ? items.map(item => object(item) && typeof item.grantId === "string" ? { ...item, grantRevoked: this.grantRevoked(item.grantId) } : item) : items;
308
+ return { ...data,
309
+ ...("attachments" in data ? { attachments: mark(data.attachments) } : {}),
310
+ ...(Array.isArray(data.references) ? { references: data.references.map(reference => object(reference) && "attachments" in reference ? { ...reference, attachments: mark(reference.attachments) } : reference) } : {}) };
311
+ }
312
+ revokedGrantIds(attachmentId: string): string[] {
313
+ return this.db.query<{ id: string }, [string]>("SELECT id FROM attachment_grants WHERE attachment_id=? AND revoked_at IS NOT NULL ORDER BY message_seq").all(attachmentId).map(row => row.id);
314
+ }
315
+ private metadata(r: Row, grantId: string): AttachmentMetadata {
316
+ const p = preparation(r);
317
+ if (!r.sha256) return fail("attachment_not_ready");
318
+ return { id: r.id, grantId, name: r.original_name, format: r.format, bytes: r.bytes, sha256: r.sha256, readMode: p.readMode,
319
+ ...(p.pageCount !== undefined ? { pageCount: p.pageCount } : {}) };
320
+ }
321
+ private references(scope: AttachmentScope, data: Readonly<Record<string, unknown>>, gate: AttachmentGate): Set<string> {
322
+ const ids = new Set<string>();
323
+ if (data.references === undefined) return ids;
324
+ if (!Array.isArray(data.references) || data.references.length > 4) return fail("invalid_attachment_input", 400);
325
+ for (const reference of data.references) {
326
+ if (!object(reference) || typeof reference.seq !== "number" || !positive(reference.seq)) fail("invalid_attachment_input", 400);
327
+ const event = this.db.query<{ conv: string; type: string; data: string }, [number]>("SELECT conv,type,data FROM events WHERE seq=?").get(reference.seq);
328
+ if (!event || event.conv !== scope.convId || !["message.user", "message.final"].includes(event.type)) fail("attachment_reference_unavailable", 410);
329
+ const source: unknown = JSON.parse(event.data);
330
+ if (!object(source)) fail("attachment_reference_unavailable", 410);
331
+ if (source.attachments === undefined) continue;
332
+ if (!Array.isArray(source.attachments)) fail("attachment_reference_unavailable", 410);
333
+ for (const item of source.attachments) {
334
+ if (!object(item) || typeof item.id !== "string" || typeof item.grantId !== "string") fail("attachment_reference_unavailable", 410);
335
+ const grant = this.grant(item.grantId), row = this.row(item.id);
336
+ if (grant.message_seq !== reference.seq || grant.attachment_id !== row.id || grant.revoked_at !== null ||
337
+ grant.conv_id !== scope.convId) fail("attachment_reference_unavailable", 410);
338
+ this.owns(row, scope, false);
339
+ this.modelAccess(this.current(scope, gate), grant.recipient_authority_epoch);
340
+ this.live(row.id);
341
+ this.original(row, gate);
342
+ ids.add(row.id);
343
+ }
344
+ }
345
+ return ids;
346
+ }
347
+ selectorKnown(hash: string): boolean { return this.db.query("SELECT hash FROM attachment_selectors WHERE hash=?").get(hash) !== null; }
348
+ /** Keeps the newest browser contexts only, so an endless stream of new sockets cannot grow the table. */
349
+ registerSelector(hash: string): void {
350
+ this.db.transaction(() => {
351
+ this.db.query("INSERT OR IGNORE INTO attachment_selectors (hash) VALUES (?)").run(hash);
352
+ this.db.query("DELETE FROM attachment_selectors WHERE rowid NOT IN (SELECT rowid FROM attachment_selectors ORDER BY rowid DESC LIMIT 256)").run();
353
+ }).immediate();
354
+ }
355
+ /** Ledger digest of the OCR text that passed inspection; the derived file alone proves nothing. */
356
+ lateOcrDigest(id: string, revision: number): string | null {
357
+ return this.db.query<{ request_digest: string }, [string]>("SELECT request_digest FROM attachment_operations WHERE principal='late-ocr' AND draft_id='late-ocr' AND operation_id=?").get(`${id}:${revision}`)?.request_digest ?? null;
358
+ }
359
+ recordLateOcr(id: string, revision: number, digest: string): void {
360
+ this.db.query("INSERT OR REPLACE INTO attachment_operations VALUES ('late-ocr','late-ocr',?,?,?,'{}')").run(`${id}:${revision}`, digest, id);
361
+ }
362
+ /** Owner lookup that hides another principal's attachment behind the same not-found answer. */
363
+ ownedScope(id: string, principal: string): AttachmentScope {
364
+ const r = this.db.query<Row, [string]>("SELECT * FROM attachments WHERE id=?").get(id);
365
+ if (!r || r.owner_principal !== principal) return fail("attachment_not_found", 404);
366
+ return rowScope(r);
367
+ }
368
+ /** Exact current manifest of one owned attachment, for the admission planner. */
369
+ manifest(id: string, principal: string): { readonly scope: AttachmentScope; readonly manifest: AttachmentManifest; readonly preparation: AttachmentPreparation; readonly name: string } {
370
+ const scope = this.ownedScope(id, principal), r = this.row(id);
371
+ if (r.state === "revoked") fail("attachment_revoked", 410);
372
+ if ((r.state !== "ready" && r.state !== "bound") || !r.sha256) fail("attachment_not_ready");
373
+ const p = preparation(r);
374
+ return { scope, preparation: p, name: r.original_name, manifest: { attachmentId: id, expectedRevision: r.revision,
375
+ preparationRevision: r.preparation_revision, originalSha256: r.sha256, representationSha256: p.representationSha256 } };
376
+ }
377
+ preparationOf(id: string): AttachmentPreparation { return preparation(this.row(id)); }
378
+ info(id: string): { readonly format: AttachmentFormat; readonly name: string; readonly state: AttachmentState; readonly sha256: string | null; readonly bytes: number; readonly draftId: string } {
379
+ const r = this.row(id);
380
+ return { format: r.format, name: r.original_name, state: r.state, sha256: r.sha256, bytes: r.bytes, draftId: r.draft_id };
381
+ }
382
+ /** A selection or sealed record whose worker died with the previous gateway process. */
383
+ unsettled(): readonly { readonly id: string; readonly revision: number }[] {
384
+ return this.db.query<{ id: string; revision: number }, []>("SELECT id,revision FROM attachments WHERE state IN ('snapshotting','sealed','preparing')").all();
385
+ }
386
+ /** The stable retry digest covers client intent, not row revisions that binding itself advances. */
387
+ retryReceipt(convId: string, submissionId: string, stableDigest: string): AttachmentAccepted | null {
388
+ const old = this.db.query<{ request_digest: string }, [string, string]>(
389
+ "SELECT request_digest FROM attachment_operations WHERE principal=? AND draft_id='submission' AND operation_id=?")
390
+ .get(`submission:${convId}`, submissionId);
391
+ if (!old) return null;
392
+ if (old.request_digest !== stableDigest) return fail("submission_conflict");
393
+ const row = this.db.query<{ receipt_json: string }, [string, string]>(
394
+ "SELECT receipt_json FROM attachment_submissions WHERE conv_id=? AND submission_id=?").get(convId, submissionId);
395
+ if (!row) return fail("submission_conflict");
396
+ return JSON.parse(row.receipt_json);
397
+ }
398
+ /** Message, grants, submission digest, pending dispatch and retry digest commit together or not at all. */
399
+ admitOnce(admission: AttachmentAdmission, data: Readonly<Record<string, unknown>>, gate: AttachmentGate, stableDigest: string,
400
+ receipt?: (ref: SecurityReceipt) => void): AttachmentAccepted {
401
+ return this.db.transaction(() => {
402
+ const accepted = this.admit(admission, data, gate, receipt);
403
+ this.db.query("INSERT INTO attachment_operations VALUES (?,?,?,?,?,?)").run(`submission:${admission.convId}`, "submission",
404
+ admission.submissionId, stableDigest, admission.attachments[0]?.attachmentId ?? "", "{}");
405
+ return accepted;
406
+ }).immediate();
407
+ }
408
+ /** The newest active grant held by exactly this conversation and recipient. */
409
+ activeGrant(attachmentId: string, convId: string, recipient: { readonly kind: "main" | "agent"; readonly id: string }, gate: AttachmentGate): SelectedAttachmentExport {
410
+ return this.db.transaction(() => {
411
+ const r = this.db.query<Row, [string]>("SELECT * FROM attachments WHERE id=?").get(attachmentId);
412
+ if (!r) return fail("attachment_revoked", 410);
413
+ const grant = this.db.query<Grant, [string, string, string]>(
414
+ "SELECT * FROM attachment_grants WHERE attachment_id=? AND conv_id=? AND recipient_id=? AND revoked_at IS NULL ORDER BY message_seq DESC LIMIT 1")
415
+ .get(attachmentId, convId, recipient.id);
416
+ if (!grant || !r.sha256 || r.recipient_kind !== recipient.kind) return fail("attachment_revoked", 410);
417
+ const receipt: SelectedAttachmentExport = { attachmentId, sourceGrantId: grant.id, sourceGrantRevision: grant.revision, originMessageSeq: grant.message_seq,
418
+ sourceConvId: r.conv_id, sourceRecipientId: r.recipient_id, sourceConvEpoch: r.conv_epoch, sourceRecipientEpoch: r.recipient_epoch,
419
+ originalSha256: r.sha256, bytes: r.bytes, format: r.format, name: r.original_name };
420
+ this.assertSelectedExportEligible(this.db, receipt, gate);
421
+ return receipt;
422
+ }).immediate();
423
+ }
424
+ /** The exact grant a stored reference names; a newer grant on the same file never stands in for it. */
425
+ grantReceipt(grantId: string, attachmentId: string, convId: string, recipient: { readonly kind: "main" | "agent"; readonly id: string }, gate: AttachmentGate): SelectedAttachmentExport {
426
+ return this.db.transaction(() => {
427
+ const grant = this.grant(grantId), r = this.row(attachmentId);
428
+ if (grant.attachment_id !== attachmentId || grant.conv_id !== convId || grant.recipient_id !== recipient.id || r.recipient_kind !== recipient.kind || !r.sha256) return fail("attachment_reference_unavailable", 410);
429
+ if (grant.revoked_at !== null) return fail("attachment_revoked", 410);
430
+ const receipt: SelectedAttachmentExport = { attachmentId, sourceGrantId: grant.id, sourceGrantRevision: grant.revision, originMessageSeq: grant.message_seq,
431
+ sourceConvId: r.conv_id, sourceRecipientId: r.recipient_id, sourceConvEpoch: r.conv_epoch, sourceRecipientEpoch: r.recipient_epoch,
432
+ originalSha256: r.sha256, bytes: r.bytes, format: r.format, name: r.original_name };
433
+ this.assertSelectedExportEligible(this.db, receipt, gate);
434
+ return receipt;
435
+ }).immediate();
436
+ }
437
+ /** Idempotent 292-facing export ledger: same key and same canonical request keep one receipt. */
438
+ recordExport(key: string, requestDigest: string, attachmentId: string, receipt: SelectedAttachmentExport): SelectedAttachmentExport {
439
+ return this.db.transaction(() => {
440
+ const old = this.db.query<{ request_digest: string; receipt_json: string }, [string]>(
441
+ "SELECT request_digest,receipt_json FROM attachment_operations WHERE principal='export' AND draft_id='export' AND operation_id=?").get(key);
442
+ if (old) {
443
+ if (old.request_digest !== requestDigest) return fail("operation_conflict");
444
+ return JSON.parse(old.receipt_json);
445
+ }
446
+ this.db.query("INSERT INTO attachment_operations VALUES ('export','export',?,?,?,?)").run(key, requestDigest, attachmentId, JSON.stringify(receipt));
447
+ return receipt;
448
+ }).immediate();
449
+ }
450
+ admit(admission: AttachmentAdmission, data: Readonly<Record<string, unknown>>, gate: AttachmentGate, receipt?: (ref: SecurityReceipt) => void): AttachmentAccepted {
451
+ validateScope(admission);
452
+ if (!UUID.test(admission.submissionId) || !SHA.test(admission.requestDigest) || !SHA.test(admission.inspectedTextSha256) ||
453
+ typeof admission.text !== "string" || !object(data) || data.text !== admission.text || !Array.isArray(admission.attachments) ||
454
+ admission.attachments.length === 0) fail("invalid_attachment_input", 400);
455
+ if (Object.keys(admission).some(key => !["convId", "draftId", "ownerPrincipal", "convEpoch", "recipient", "submissionId",
456
+ "requestDigest", "inspectedTextSha256", "text", "attachments"].includes(key)) ||
457
+ Object.keys(admission.recipient).some(key => !["kind", "id", "epoch"].includes(key))) fail("invalid_attachment_input", 400);
458
+ if (admission.attachments.length > 4) fail("too_many_attachments");
459
+ const { requestDigest, ...body } = admission;
460
+ if (attachmentRequestDigest(body, data) !== requestDigest) fail("request_digest_mismatch");
461
+ for (const m of admission.attachments) {
462
+ if (!m || !UUID.test(m.attachmentId) || !positive(m.expectedRevision) || !positive(m.preparationRevision) ||
463
+ !SHA.test(m.originalSha256) || !SHA.test(m.representationSha256)) fail("invalid_attachment_input", 400);
464
+ if (Object.keys(m).some(key => !["attachmentId", "expectedRevision", "preparationRevision", "originalSha256",
465
+ "representationSha256"].includes(key))) fail("invalid_attachment_input", 400);
466
+ }
467
+ if (new Set(admission.attachments.map(a => a.attachmentId)).size !== admission.attachments.length) fail("invalid_attachment_input", 400);
468
+ const direct = new Set<string>();
469
+ if (data.attachments !== undefined) {
470
+ if (!Array.isArray(data.attachments)) fail("invalid_attachment_input", 400);
471
+ for (const id of data.attachments) {
472
+ if (typeof id !== "string" || !UUID.test(id) || direct.has(id)) fail("invalid_attachment_input", 400);
473
+ if (!admission.attachments.some(m => m.attachmentId === id)) fail("attachment_manifest_conflict");
474
+ direct.add(id);
475
+ }
476
+ }
477
+ return this.db.transaction(() => {
478
+ const current = this.current(admission, gate);
479
+ const old = this.db.query<{ request_digest: string; receipt_json: string }, [string, string]>(
480
+ "SELECT request_digest,receipt_json FROM attachment_submissions WHERE conv_id=? AND submission_id=?").get(admission.convId, admission.submissionId);
481
+ if (old) {
482
+ if (old.request_digest !== requestDigest) fail("submission_conflict");
483
+ return JSON.parse(old.receipt_json);
484
+ }
485
+ this.modelAccess(current);
486
+ const referenced = this.references(admission, data, gate);
487
+ for (const id of referenced) if (!admission.attachments.some(m => m.attachmentId === id)) fail("attachment_reference_unavailable", 410);
488
+ const rows = admission.attachments.map(m => {
489
+ const r = this.row(m.attachmentId);
490
+ this.live(r.id);
491
+ this.owns(r, admission, r.state !== "bound");
492
+ if (r.state === "bound" && !referenced.has(r.id)) {
493
+ if (!direct.has(r.id)) fail("attachment_reference_unavailable", 410);
494
+ // A direct re-selection is explicit, but must still have a current active grant in this conversation.
495
+ this.exportEvidence(admission, r.id, gate);
496
+ }
497
+ if (r.state !== "ready" && r.state !== "bound") fail(r.state === "revoked" ? "attachment_revoked" : "attachment_not_ready");
498
+ const p = preparation(r);
499
+ if (r.revision !== m.expectedRevision || r.preparation_revision !== m.preparationRevision || r.sha256 !== m.originalSha256 ||
500
+ p.representationSha256 !== m.representationSha256) fail("attachment_manifest_conflict");
501
+ this.original(r, gate);
502
+ return r;
503
+ });
504
+ if (gate.inspect(this.db, admission, rows.map(preparation)) !== undefined) fail("attachment_provider_invalid", 503);
505
+ const metadata = rows.map(r => this.metadata(r, crypto.randomUUID()));
506
+ const message = this.accept(admission.convId, { ...data, origin: "owner-attachment", attachments: metadata }, receipt);
507
+ const pending = this.db.query<{ seq: number }, [string, number]>(
508
+ "SELECT seq FROM events WHERE conv=? AND type='main.user.pending' AND json_extract(data,'$.message_seq')=?")
509
+ .get(admission.convId, message.seq);
510
+ if (!pending) fail("attachment_provider_invalid", 503);
511
+ for (const meta of metadata) {
512
+ this.db.query(`INSERT INTO attachment_grants VALUES (?,?,?,?,?,1,NULL,?,?,?)`).run(meta.grantId, meta.id, admission.convId,
513
+ admission.recipient.id, message.seq, admission.convEpoch, admission.recipient.epoch, current.authority?.epoch ?? null);
514
+ this.db.query("UPDATE attachments SET state='bound',revision=revision+1 WHERE id=? AND state='ready'").run(meta.id);
515
+ }
516
+ const accepted: AttachmentAccepted = { convId: admission.convId, submissionId: admission.submissionId,
517
+ messageSeq: message.seq, pendingDispatchSeq: pending.seq, attachments: metadata };
518
+ this.db.query("INSERT INTO attachment_submissions VALUES (?,?,?,?,?)")
519
+ .run(admission.convId, admission.submissionId, requestDigest, message.seq, JSON.stringify(accepted));
520
+ return accepted;
521
+ }).immediate();
522
+ }
523
+ exportEvidence(scope: AttachmentScope, id: string, gate: AttachmentGate): SelectedAttachmentExport {
524
+ return this.db.transaction(() => {
525
+ const r = this.row(id);
526
+ this.owns(r, scope, false);
527
+ const grant = this.db.query<Grant, [string, string]>(
528
+ "SELECT * FROM attachment_grants WHERE attachment_id=? AND conv_id=? AND revoked_at IS NULL ORDER BY message_seq DESC LIMIT 1").get(id, scope.convId);
529
+ if (!grant || !r.sha256) return fail("attachment_revoked", 410);
530
+ const receipt = { attachmentId: id, sourceGrantId: grant.id, sourceGrantRevision: grant.revision, originMessageSeq: grant.message_seq,
531
+ sourceConvId: r.conv_id, sourceRecipientId: r.recipient_id, sourceConvEpoch: r.conv_epoch, sourceRecipientEpoch: r.recipient_epoch,
532
+ originalSha256: r.sha256, bytes: r.bytes, format: r.format, name: r.original_name };
533
+ this.assertSelectedExportEligible(this.db, receipt, gate);
534
+ return receipt;
535
+ }).immediate();
536
+ }
537
+ /** Fits confirmed 292's synchronous gate: bind gate in the trusted composition root. No bytes or recipient grant is minted here. */
538
+ assertSelectedExportEligible(tx: Database, receipt: SelectedAttachmentExport, gate: AttachmentGate): undefined {
539
+ if (tx !== this.db || !tx.inTransaction) return fail("attachment_transaction_required");
540
+ const r = this.row(receipt.attachmentId), grant = this.grant(receipt.sourceGrantId);
541
+ this.live(r.id);
542
+ if (grant.revoked_at !== null || r.state !== "bound") fail("attachment_revoked", 410);
543
+ if (grant.attachment_id !== r.id || grant.revision !== receipt.sourceGrantRevision || grant.message_seq !== receipt.originMessageSeq ||
544
+ r.conv_id !== receipt.sourceConvId || r.recipient_id !== receipt.sourceRecipientId || r.conv_epoch !== receipt.sourceConvEpoch ||
545
+ r.recipient_epoch !== receipt.sourceRecipientEpoch || r.sha256 !== receipt.originalSha256 || r.bytes !== receipt.bytes ||
546
+ r.format !== receipt.format || r.original_name !== receipt.name || grant.conv_epoch !== r.conv_epoch ||
547
+ grant.recipient_epoch !== r.recipient_epoch || grant.conv_id !== r.conv_id || grant.recipient_id !== r.recipient_id) fail("attachment_scope_mismatch");
548
+ this.modelAccess(this.current(rowScope(r), gate, false), grant.recipient_authority_epoch);
549
+ this.original(r, gate);
550
+ return undefined;
551
+ }
552
+ revoke(scope: AttachmentScope, grantId: string, expectedRevision: number, gate: AttachmentGate) {
553
+ return this.db.transaction(() => {
554
+ this.current(scope, gate);
555
+ const grant = this.grant(grantId), r = this.row(grant.attachment_id);
556
+ this.owns(r, scope, false);
557
+ if (grant.revision !== expectedRevision || grant.revoked_at !== null) fail("attachment_cas_conflict");
558
+ return { grantId, revision: grant.revision + 1, pendingDelivery: this.retireGrant(grant) };
559
+ }).immediate();
560
+ }
561
+ /** Serialized on this exact grant: a consumed or unknown delivery is reported and no other activation is touched. */
562
+ private retireGrant(grant: Grant): "consumed" | "uncertain" | "already_dispatched" | "cancelled" {
563
+ this.db.query("UPDATE attachment_grants SET revoked_at=?,revision=revision+1 WHERE id=?").run(new Date().toISOString(), grant.id);
564
+ if (!this.db.query("SELECT id FROM attachment_grants WHERE attachment_id=? AND revoked_at IS NULL").get(grant.attachment_id)) {
565
+ this.db.query("UPDATE attachments SET state='revoked',revision=revision+1 WHERE id=?").run(grant.attachment_id);
566
+ }
567
+ const attempted = this.db.query<{ seq: number }, [string, number]>("SELECT seq FROM events WHERE conv=? AND type='main.input' AND json_extract(data,'$.input_ref.message_seq')=?")
568
+ .get(grant.conv_id, grant.message_seq);
569
+ const dispatched = this.db.query("SELECT seq FROM events WHERE conv=? AND type='main.user.dispatched' AND json_extract(data,'$.message_seq')=?")
570
+ .get(grant.conv_id, grant.message_seq);
571
+ if (!attempted && !dispatched) this.db.query("INSERT INTO events(conv,type,data,ts) VALUES (?,'main.user.dispatched',?,?)")
572
+ .run(grant.conv_id, JSON.stringify({ message_seq: grant.message_seq, reason: "attachment_revoked" }), new Date().toISOString());
573
+ if (!attempted) return dispatched ? "already_dispatched" : "cancelled";
574
+ const consumed = this.db.query("SELECT seq FROM events WHERE conv=? AND type='main.input.consumed' AND json_extract(data,'$.input_seq')=?").get(grant.conv_id, attempted.seq);
575
+ return consumed ? "consumed" : "uncertain";
576
+ }
577
+ recipientEpoch(recipientId: string): number {
578
+ return this.db.query<{ epoch: number }, [string]>("SELECT epoch FROM attachment_recipient_epochs WHERE recipient_id=?").get(recipientId)?.epoch ?? 1;
579
+ }
580
+ /** Called only once the exact session is closed. Visibility, grants, unbound records and the new epoch change together. */
581
+ retireRecipient(convId: string, recipientId: string, hide: () => void): readonly string[] {
582
+ return this.db.transaction(() => {
583
+ hide();
584
+ for (const grant of this.db.query<Grant, [string, string]>("SELECT * FROM attachment_grants WHERE conv_id=? AND recipient_id=? AND revoked_at IS NULL").all(convId, recipientId)) this.retireGrant(grant);
585
+ const unbound = this.db.query<{ id: string }, [string, string]>(`SELECT id FROM attachments WHERE conv_id=? AND recipient_id=?
586
+ AND state NOT IN ('bound','revoked','discarded') AND id NOT IN (SELECT id FROM attachment_tombstones)`).all(convId, recipientId).map(row => row.id);
587
+ for (const id of unbound) this.db.query("UPDATE attachments SET state='discarded',revision=revision+1,job_generation=? WHERE id=?").run(crypto.randomUUID(), id);
588
+ this.db.query("INSERT INTO attachment_recipient_epochs VALUES (?,2) ON CONFLICT(recipient_id) DO UPDATE SET epoch=epoch+1").run(recipientId);
589
+ return unbound;
590
+ }).immediate();
591
+ }
592
+ /** Readers belong to one process; after a restart no lease can still be delivering. */
593
+ clearReadLeases(): void { this.db.query("DELETE FROM attachment_read_leases").run(); }
594
+ resumePreparation(id: string, jobGeneration: string): AttachmentJob & { readonly principal: string; readonly convId: string; readonly encoding: AttachmentEncoding | null } {
595
+ return this.db.transaction(() => {
596
+ const r = this.row(id);
597
+ this.live(id);
598
+ if (r.state !== "sealed" || !r.sha256) fail("attachment_cas_conflict");
599
+ this.db.query("UPDATE attachments SET state='preparing',revision=revision+1,preparation_revision=preparation_revision+1,prepared_manifest_json=NULL,error_code=NULL,job_generation=? WHERE id=?").run(jobGeneration, id);
600
+ return { ...job(this.row(id)), principal: r.owner_principal, convId: r.conv_id, encoding: r.preparation_encoding };
601
+ }).immediate();
602
+ }
603
+ /** Sealed records that claim to be usable; startup compares each with its bytes. */
604
+ integrityRows(): readonly { readonly id: string; readonly bytes: number; readonly sha256: string }[] {
605
+ return this.db.query<{ id: string; bytes: number; sha256: string }, []>(`SELECT id,bytes,sha256 FROM attachments WHERE state IN ('ready','bound') AND sha256 IS NOT NULL
606
+ AND error_code IS NULL AND id NOT IN (SELECT id FROM attachment_tombstones)`).all();
607
+ }
608
+ /** Bound records keep their state so the owner can still revoke the grants. */
609
+ markUnavailable(id: string): void {
610
+ this.db.query("UPDATE attachments SET state=CASE state WHEN 'ready' THEN 'error' ELSE state END,revision=revision+1,error_code='attachment_original_unavailable' WHERE id=?").run(id);
611
+ }
612
+ private busy(id: string): { readonly grants: number; readonly readers: number; readonly uncertain: number } {
613
+ const n = (sql: string) => this.db.query<{ n: number }, [string]>(sql).get(id)?.n ?? 0;
614
+ return { grants: n("SELECT count(*) n FROM attachment_grants WHERE attachment_id=? AND revoked_at IS NULL"),
615
+ readers: n("SELECT count(*) n FROM attachment_read_leases WHERE attachment_id=?"),
616
+ uncertain: n(`SELECT count(*) n FROM events e JOIN attachment_grants g ON json_extract(e.data,'$.input_ref.message_seq')=g.message_seq
617
+ AND e.conv=g.conv_id WHERE g.attachment_id=? AND e.type='main.input'
618
+ AND NOT EXISTS (SELECT 1 FROM events settled WHERE settled.conv=e.conv AND settled.type IN ('main.input.ack','main.input.handled')
619
+ AND json_extract(settled.data,'$.input_seq')=e.seq)`) };
620
+ }
621
+ /** Terminal records not yet in the Trash, including a tombstone whose earlier move failed. */
622
+ cleanupRows() {
623
+ return this.db.query<{ id: string; state: string; bytes: number }, []>(`SELECT a.id,a.state,a.bytes FROM attachments a LEFT JOIN attachment_tombstones t ON t.id=a.id
624
+ WHERE t.trashed_at IS NULL AND (t.id IS NOT NULL OR a.state IN ('discarded','revoked','error')) ORDER BY a.rowid`).all().map(row => ({ ...row, busy: this.busy(row.id) }));
625
+ }
626
+ tombstoneOf(id: string): { readonly digest: string | null; readonly submissionKeys: readonly string[]; readonly reason: string; readonly createdAt: string; readonly trashed: boolean; readonly trashedAt: string | null } | null {
627
+ const row = this.db.query<{ digest: string | null; submission_keys_json: string; reason: string; created_at: string; trashed_at: string | null }, [string]>("SELECT * FROM attachment_tombstones WHERE id=?").get(id);
628
+ return row && { digest: row.digest, submissionKeys: JSON.parse(row.submission_keys_json), reason: row.reason, createdAt: row.created_at, trashed: row.trashed_at !== null, trashedAt: row.trashed_at };
629
+ }
630
+ markTrashed(id: string): void { this.db.query("UPDATE attachment_tombstones SET trashed_at=? WHERE id=? AND trashed_at IS NULL").run(new Date().toISOString(), id); }
631
+ /** Startup supplies a fresh worker generation. No source path is returned or reopened. */
632
+ recover(id: string, expectedRevision: number, generation: string, gate: AttachmentGate): AttachmentJob {
633
+ if (!UUID.test(generation)) fail("invalid_attachment_input", 400);
634
+ return this.db.transaction(() => {
635
+ const r = this.row(id);
636
+ this.live(id);
637
+ if (r.revision !== expectedRevision) fail("attachment_cas_conflict");
638
+ if (r.state === "discarded" || r.state === "revoked") return job(r);
639
+ if (!r.sha256) {
640
+ this.db.query("UPDATE attachments SET state='error',revision=revision+1,error_code='snapshot_incomplete',job_generation=? WHERE id=?").run(generation, id);
641
+ } else {
642
+ const original = gate.original(this.db, id);
643
+ if (!original || original.sha256 !== r.sha256 || original.bytes !== r.bytes) {
644
+ this.db.query("UPDATE attachments SET state='error',revision=revision+1,error_code='attachment_original_unavailable',job_generation=? WHERE id=?")
645
+ .run(generation, id);
646
+ } else if (r.state === "sealed" || r.state === "preparing") {
647
+ this.db.query("UPDATE attachments SET state='sealed',revision=revision+1,prepared_manifest_json=NULL,job_generation=? WHERE id=?")
648
+ .run(generation, id);
649
+ }
650
+ }
651
+ return job(this.row(id));
652
+ }).immediate();
653
+ }
654
+ acquireRead(receipt: SelectedAttachmentExport, gate: AttachmentGate): string {
655
+ return this.db.transaction(() => {
656
+ this.assertSelectedExportEligible(this.db, receipt, gate);
657
+ const id = crypto.randomUUID();
658
+ this.db.query("INSERT INTO attachment_read_leases VALUES (?,?,?)").run(id, receipt.attachmentId, JSON.stringify(receipt));
659
+ return id;
660
+ }).immediate();
661
+ }
662
+ /** Linearizes content release against revoke; caller publishes only if this succeeds. */
663
+ releaseRead(id: string, gate: AttachmentGate): SelectedAttachmentExport {
664
+ return this.db.transaction(() => {
665
+ const lease = this.db.query<{ receipt_json: string }, [string]>("SELECT receipt_json FROM attachment_read_leases WHERE id=?").get(id);
666
+ if (!lease) return fail("attachment_cas_conflict");
667
+ const receipt: SelectedAttachmentExport = JSON.parse(lease.receipt_json);
668
+ this.assertSelectedExportEligible(this.db, receipt, gate);
669
+ this.db.query("DELETE FROM attachment_read_leases WHERE id=?").run(id);
670
+ return receipt;
671
+ }).immediate();
672
+ }
673
+ abandonRead(id: string): void { this.db.query("DELETE FROM attachment_read_leases WHERE id=?").run(id); }
674
+ /** T9 performs the explicit physical Trash action only after this reservation; receipts and operation keys remain. */
675
+ tombstone(scope: AttachmentScope, id: string, expectedRevision: number, reason: string, gate: AttachmentGate): void {
676
+ if (!/^[a-z][a-z0-9_]{0,79}$/.test(reason)) fail("invalid_attachment_input", 400);
677
+ this.db.transaction(() => {
678
+ this.current(scope, gate);
679
+ const r = this.row(id);
680
+ this.owns(r, scope, false);
681
+ if (r.revision !== expectedRevision) fail("attachment_cas_conflict");
682
+ this.reserveTombstone(r, reason);
683
+ }).immediate();
684
+ }
685
+ /** The authenticated operator route: it also reaches records whose owner or agent scope no longer exists. */
686
+ operatorTombstone(id: string, reason: string): void {
687
+ this.db.transaction(() => this.reserveTombstone(this.row(id), reason)).immediate();
688
+ }
689
+ private reserveTombstone(r: Row, reason: string): void {
690
+ this.live(r.id);
691
+ const busy = this.busy(r.id);
692
+ if (!["discarded", "revoked", "error"].includes(r.state) || busy.grants > 0 || busy.readers > 0 || busy.uncertain > 0) fail("attachment_busy");
693
+ const keys = this.db.query<{ submission_id: string }, [string]>(`SELECT s.submission_id FROM attachment_submissions s
694
+ JOIN attachment_grants g ON s.message_seq=g.message_seq WHERE g.attachment_id=?`).all(r.id).map(row => row.submission_id);
695
+ this.db.query("INSERT INTO attachment_tombstones (id,digest,submission_keys_json,reason,created_at) VALUES (?,?,?,?,?)")
696
+ .run(r.id, r.sha256, JSON.stringify(keys), reason, new Date().toISOString());
697
+ }
698
+ }