@odla-ai/brand 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -23,17 +23,23 @@ __export(src_exports, {
23
23
  ASSET_CONTENT_TYPES: () => ASSET_CONTENT_TYPES,
24
24
  ASSET_KINDS: () => ASSET_KINDS,
25
25
  BOOK_STATUSES: () => BOOK_STATUSES,
26
+ BRAND_AGENT_PROFILE: () => BRAND_AGENT_PROFILE,
26
27
  BRAND_CHART_TOKENS: () => BRAND_CHART_TOKENS,
27
28
  BRAND_CHAT_TOKENS: () => BRAND_CHAT_TOKENS,
28
29
  BRAND_DERIVED_TOKENS: () => BRAND_DERIVED_TOKENS,
30
+ BRAND_DISCUSSION_REFERENCE_KINDS: () => BRAND_DISCUSSION_REFERENCE_KINDS,
29
31
  BRAND_EMITTED_TOKENS: () => BRAND_EMITTED_TOKENS,
30
32
  BRAND_INSTRUCTIONS: () => BRAND_INSTRUCTIONS,
31
33
  BRAND_NS: () => BRAND_NS,
32
34
  BRAND_REQUIRED_TOKENS: () => BRAND_REQUIRED_TOKENS,
33
35
  BRAND_RULES: () => BRAND_RULES,
34
36
  BRAND_SCHEMA: () => BRAND_SCHEMA,
37
+ BrandConflictError: () => BrandConflictError,
38
+ BrandForbiddenError: () => BrandForbiddenError,
39
+ BrandGoneError: () => BrandGoneError,
35
40
  BrandInputError: () => BrandInputError,
36
41
  BrandNotFoundError: () => BrandNotFoundError,
42
+ BrandReviewStateChangedError: () => BrandReviewStateChangedError,
37
43
  CHART_DELTA_MIN: () => CHART_DELTA_MIN,
38
44
  CHAT_MESSAGE_NS: () => CHAT_MESSAGE_NS,
39
45
  CSS_NAMED_COLORS: () => CSS_NAMED_COLORS,
@@ -62,15 +68,22 @@ __export(src_exports, {
62
68
  assertSectionContent: () => assertSectionContent,
63
69
  assertSwatches: () => assertSwatches,
64
70
  assetTools: () => assetTools,
71
+ attachedBrandAssetIds: () => attachedBrandAssetIds,
65
72
  audienceFanoutOps: () => audienceFanoutOps,
66
73
  base64FromBytes: () => base64FromBytes,
74
+ beginAssetDeleteOps: () => beginAssetDeleteOps,
67
75
  bookForChannel: () => bookForChannel,
68
76
  bookTools: () => bookTools,
77
+ brandAuthorityRef: () => brandAuthorityRef,
69
78
  brandBotTrigger: () => brandBotTrigger,
79
+ brandDiscussionReferenceHref: () => brandDiscussionReferenceHref,
80
+ brandDiscussionReferenceId: () => brandDiscussionReferenceId,
70
81
  brandInputFor: () => brandInputFor,
71
82
  brandIntegration: () => brandIntegration,
83
+ brandJsonDigest: () => brandJsonDigest,
72
84
  brandRules: () => brandRules,
73
85
  brandSkill: () => brandSkill,
86
+ canonicalBrandJson: () => canonicalBrandJson,
74
87
  capString: () => capString,
75
88
  capStringArray: () => capStringArray,
76
89
  clamp01: () => clamp01,
@@ -89,9 +102,13 @@ __export(src_exports, {
89
102
  deriveDarkTokens: () => deriveDarkTokens,
90
103
  derivePalette: () => derivePalette,
91
104
  dispatchBrandTurn: () => dispatchBrandTurn,
105
+ finishAssetDeleteOps: () => finishAssetDeleteOps,
106
+ formatBrandDiscussionReference: () => formatBrandDiscussionReference,
92
107
  hexToOklch: () => hexToOklch,
93
108
  hslToRgb: () => hslToRgb,
94
109
  inSrgbGamut: () => inSrgbGamut,
110
+ isBoundedBrandJson: () => isBoundedBrandJson,
111
+ isBrandHumanAuthorityConsumption: () => isBrandHumanAuthorityConsumption,
95
112
  linearToSrgb: () => linearToSrgb,
96
113
  mapPaletteToTokens: () => mapPaletteToTokens,
97
114
  meetsAA: () => meetsAA,
@@ -104,10 +121,13 @@ __export(src_exports, {
104
121
  oklchToHex: () => oklchToHex,
105
122
  oklchToOklab: () => oklchToOklab,
106
123
  paletteTools: () => paletteTools,
124
+ parseBrandDiscussionReference: () => parseBrandDiscussionReference,
107
125
  parseBrandDispatch: () => parseBrandDispatch,
108
126
  parseHex: () => parseHex,
109
127
  pickTextOn: () => pickTextOn,
128
+ proposalReviewSnapshot: () => proposalReviewSnapshot,
110
129
  proposePaletteOps: () => proposePaletteOps,
130
+ proposeSectionOps: () => proposeSectionOps,
111
131
  readTools: () => readTools,
112
132
  recordAnalysisOps: () => recordAnalysisOps,
113
133
  rejectProposalOps: () => rejectProposalOps,
@@ -125,10 +145,10 @@ __export(src_exports, {
125
145
  tetradic: () => tetradic,
126
146
  tintShadeRamp: () => tintShadeRamp,
127
147
  toHex: () => toHex,
128
- tombstoneAssetOps: () => tombstoneAssetOps,
129
148
  triadic: () => triadic,
130
149
  updateBookOps: () => updateBookOps,
131
- upsertSectionOps: () => upsertSectionOps
150
+ upsertSectionOps: () => upsertSectionOps,
151
+ verifyBrandApprovalReceipt: () => verifyBrandApprovalReceipt
132
152
  });
133
153
  module.exports = __toCommonJS(src_exports);
134
154
 
@@ -138,6 +158,7 @@ var BRAND_NS = {
138
158
  section: "brand_section",
139
159
  palette: "brand_palette",
140
160
  proposal: "brand_proposal",
161
+ approvalReceipt: "brand_approval_receipt",
141
162
  asset: "brand_asset"
142
163
  };
143
164
  var BOOK_STATUSES = ["draft", "active", "archived"];
@@ -145,9 +166,9 @@ var SECTION_KINDS = ["palette", "typography", "voice", "logo", "imagery"];
145
166
  var SECTION_STATUSES = ["draft", "approved"];
146
167
  var PALETTE_STATUSES = ["active", "archived"];
147
168
  var PALETTE_SOURCES = ["extracted", "derived", "manual"];
148
- var PROPOSAL_KINDS = ["palette", "typography", "voice", "logo"];
169
+ var PROPOSAL_KINDS = SECTION_KINDS;
149
170
  var PROPOSAL_STATUSES = ["open", "accepted", "rejected", "superseded"];
150
- var ASSET_KINDS = ["logo", "wordmark", "inspiration", "document", "font", "other"];
171
+ var ASSET_KINDS = ["logo", "wordmark", "inspiration", "document", "other"];
151
172
  var SWATCH_ROLES = [
152
173
  "primary",
153
174
  "secondary",
@@ -163,6 +184,25 @@ var SWATCH_ROLES = [
163
184
  "custom"
164
185
  ];
165
186
 
187
+ // src/agent-profile.ts
188
+ var BRAND_AGENT_PROFILE = {
189
+ version: 1,
190
+ projectCapabilities: ["brand.read", "brand.edit"],
191
+ semanticOperations: [
192
+ "brand.proposal.create",
193
+ "brand.asset.analysis.record",
194
+ "brand.asset.content.read"
195
+ ],
196
+ rawBrandWrites: false,
197
+ rawFileOperations: false,
198
+ approval: {
199
+ principalKind: "human",
200
+ projectCapability: "brand.approve",
201
+ capability: "brand.proposal.resolve",
202
+ exactAction: true
203
+ }
204
+ };
205
+
166
206
  // src/errors.ts
167
207
  var BrandInputError = class extends Error {
168
208
  fields;
@@ -178,6 +218,31 @@ var BrandNotFoundError = class extends Error {
178
218
  this.name = "BrandNotFoundError";
179
219
  }
180
220
  };
221
+ var BrandGoneError = class extends Error {
222
+ constructor(message = "brand authority or resource is no longer available") {
223
+ super(message);
224
+ this.name = "BrandGoneError";
225
+ }
226
+ };
227
+ var BrandForbiddenError = class extends Error {
228
+ constructor(message = "forbidden") {
229
+ super(message);
230
+ this.name = "BrandForbiddenError";
231
+ }
232
+ };
233
+ var BrandConflictError = class extends Error {
234
+ constructor(message) {
235
+ super(message);
236
+ this.name = "BrandConflictError";
237
+ }
238
+ };
239
+ var BrandReviewStateChangedError = class extends BrandConflictError {
240
+ code = "brand_review_state_changed";
241
+ constructor(message = "proposal or live brand dependencies changed; review and approve again") {
242
+ super(message);
243
+ this.name = "BrandReviewStateChangedError";
244
+ }
245
+ };
181
246
 
182
247
  // src/deps.ts
183
248
  async function defaultFetchBytes(url) {
@@ -213,17 +278,21 @@ var BRAND_SCHEMA = {
213
278
  [BRAND_NS.book]: {
214
279
  attrs: {
215
280
  id: uniq("string"),
281
+ version: a("number"),
216
282
  slug: uniq("string"),
217
283
  name: idx("string"),
218
284
  status: idx("string"),
219
285
  // draft | active | archived
220
286
  ownerId: idx("string"),
287
+ createdAuthorityRef: idx("string"),
288
+ rosterAuthorityRef: opt("string"),
221
289
  memberIds: a("json"),
222
290
  // the auth roster, incl. the bot agent id
223
- channelId: idx("string", true),
291
+ channelId: a("string", { unique: true, indexed: true, optional: true }),
224
292
  activePaletteId: opt("string"),
293
+ activationRevision: a("number"),
225
294
  tokens: opt("json"),
226
- // { light, dark, warnings, compiledAt }
295
+ // maps + complete palette/typography receipt provenance
227
296
  summary: opt("string"),
228
297
  createdAt: idx("date"),
229
298
  updatedAt: idx("date")
@@ -241,7 +310,9 @@ var BRAND_SCHEMA = {
241
310
  content: a("json"),
242
311
  audience: a("json"),
243
312
  updatedBy: a("string"),
244
- updatedAt: idx("date")
313
+ updatedAt: idx("date"),
314
+ approvalReceiptId: opt("string"),
315
+ approvalActionDigest: opt("string")
245
316
  }
246
317
  },
247
318
  [BRAND_NS.palette]: {
@@ -257,7 +328,9 @@ var BRAND_SCHEMA = {
257
328
  source: a("string"),
258
329
  // extracted | derived | manual
259
330
  rationale: opt("string"),
260
- proposalId: opt("string"),
331
+ proposalId: idx("string"),
332
+ approvalReceiptId: a("string"),
333
+ approvalActionDigest: a("string"),
261
334
  audience: a("json"),
262
335
  createdAt: idx("date"),
263
336
  updatedAt: idx("date")
@@ -273,14 +346,45 @@ var BRAND_SCHEMA = {
273
346
  // open | accepted | rejected | superseded
274
347
  payload: a("json"),
275
348
  rationale: a("string"),
276
- sourceAssetId: opt("string"),
277
- messageId: opt("string"),
349
+ provenance: a("json"),
350
+ // exact source asset/message/turn ids (nullable asset)
351
+ reviewDigest: idx("string"),
278
352
  audience: a("json"),
279
353
  createdBy: a("string"),
354
+ createdAuthorityRef: a("string"),
280
355
  createdAt: idx("date"),
281
356
  resolvedBy: opt("string"),
282
357
  resolvedAt: opt("date"),
283
- resolutionNote: opt("string")
358
+ resolutionNote: opt("string"),
359
+ approvedBy: opt("string"),
360
+ appliedBy: opt("string"),
361
+ resolutionReceiptId: opt("string"),
362
+ resolutionActionDigest: opt("string")
363
+ }
364
+ },
365
+ [BRAND_NS.approvalReceipt]: {
366
+ attrs: {
367
+ id: uniq("string"),
368
+ version: a("number"),
369
+ mutationKey: uniq("string"),
370
+ bookId: idx("string"),
371
+ proposalId: idx("string"),
372
+ resolution: idx("string"),
373
+ // accepted | rejected
374
+ paletteId: opt("string"),
375
+ resolutionNote: opt("string"),
376
+ reviewedProposal: a("json"),
377
+ actionDigest: idx("string"),
378
+ decisionBinding: a("json"),
379
+ approvedBy: idx("string"),
380
+ approvedByKind: a("string"),
381
+ appliedBy: idx("string"),
382
+ appliedByKind: a("string"),
383
+ authorityRef: idx("string"),
384
+ authorityCapability: a("string"),
385
+ authorityConsumption: a("json"),
386
+ createdAt: idx("date"),
387
+ receiptDigest: idx("string")
284
388
  }
285
389
  },
286
390
  [BRAND_NS.asset]: {
@@ -288,59 +392,94 @@ var BRAND_SCHEMA = {
288
392
  id: uniq("string"),
289
393
  bookId: idx("string"),
290
394
  kind: idx("string"),
291
- // logo | wordmark | inspiration | document | font | other
395
+ // logo | wordmark | inspiration | document | other
292
396
  path: idx("string"),
293
- url: a("string"),
397
+ storageObjectId: idx("string"),
398
+ contentDigest: idx("string"),
294
399
  contentType: a("string"),
295
400
  size: a("number"),
401
+ status: idx("string"),
402
+ // live | deleting | deleted
296
403
  title: opt("string"),
297
404
  analysis: opt("json"),
298
405
  // { description, dominantColors: hex[], tags }
299
406
  analyzedAt: opt("date"),
407
+ analysisRevision: a("number"),
408
+ analysisDigest: opt("string"),
409
+ analyzedBy: opt("string"),
410
+ analyzedAuthorityRef: opt("string"),
300
411
  audience: a("json"),
301
412
  uploadedBy: a("string"),
413
+ uploadedAuthorityRef: a("string"),
302
414
  createdAt: idx("date"),
303
- deletedAt: opt("date")
415
+ deletedAt: opt("date"),
304
416
  // tombstone — asset rows are never row-deleted
417
+ deletedBy: opt("string"),
418
+ deletedAuthorityRef: opt("string")
305
419
  }
306
420
  }
307
421
  },
308
- links: {}
422
+ links: {
423
+ brandSectionBook: {
424
+ forward: { on: BRAND_NS.section, has: "one", label: "book" },
425
+ reverse: { on: BRAND_NS.book, has: "many", label: "sections" }
426
+ },
427
+ brandPaletteBook: {
428
+ forward: { on: BRAND_NS.palette, has: "one", label: "book" },
429
+ reverse: { on: BRAND_NS.book, has: "many", label: "palettes" }
430
+ },
431
+ brandProposalBook: {
432
+ forward: { on: BRAND_NS.proposal, has: "one", label: "book" },
433
+ reverse: { on: BRAND_NS.book, has: "many", label: "proposals" }
434
+ },
435
+ brandApprovalReceiptBook: {
436
+ forward: { on: BRAND_NS.approvalReceipt, has: "one", label: "book" },
437
+ reverse: { on: BRAND_NS.book, has: "many", label: "approvalReceipts" }
438
+ },
439
+ brandAssetBook: {
440
+ forward: { on: BRAND_NS.asset, has: "one", label: "book" },
441
+ reverse: { on: BRAND_NS.book, has: "many", label: "assets" }
442
+ }
443
+ }
309
444
  };
310
445
 
311
446
  // src/rules.ts
312
- var AUDIENCE = "auth.id in data.audience";
447
+ var CURRENT_BOOK_MEMBER = "ref('book.memberIds').exists(members, auth.id in members)";
313
448
  var BRAND_RULES = {
314
449
  [BRAND_NS.book]: {
315
450
  view: "auth.id in data.memberIds",
316
- // Creator is the owner and must include itself in the roster.
317
- create: "auth.signedIn && auth.id == data.ownerId && auth.id in data.memberIds",
318
- update: "auth.id == data.ownerId",
319
- delete: "auth.id == data.ownerId"
451
+ // Every mutation is worker-routed: CEL cannot distinguish an update from
452
+ // a retract, so effect-bearing fields must never be browser-writable.
453
+ create: "false",
454
+ update: "false",
455
+ delete: "false"
320
456
  },
321
457
  [BRAND_NS.section]: {
322
- view: AUDIENCE,
323
- create: AUDIENCE,
324
- update: AUDIENCE,
458
+ view: CURRENT_BOOK_MEMBER,
459
+ create: "false",
460
+ update: "false",
325
461
  delete: "false"
326
- // sections are upserted in place, never removed
327
462
  },
328
463
  [BRAND_NS.palette]: {
329
- view: AUDIENCE,
330
- create: AUDIENCE,
331
- update: AUDIENCE,
464
+ view: CURRENT_BOOK_MEMBER,
465
+ create: "false",
466
+ update: "false",
332
467
  delete: "false"
333
- // archive via status, keep provenance
334
468
  },
335
469
  [BRAND_NS.proposal]: {
336
- view: AUDIENCE,
337
- create: AUDIENCE,
338
- update: AUDIENCE,
470
+ view: CURRENT_BOOK_MEMBER,
471
+ create: "false",
472
+ update: "false",
473
+ delete: "false"
474
+ },
475
+ [BRAND_NS.approvalReceipt]: {
476
+ view: CURRENT_BOOK_MEMBER,
477
+ create: "false",
478
+ update: "false",
339
479
  delete: "false"
340
- // resolution history is the audit trail
341
480
  },
342
481
  [BRAND_NS.asset]: {
343
- view: "false",
482
+ view: CURRENT_BOOK_MEMBER,
344
483
  create: "false",
345
484
  update: "false",
346
485
  delete: "false"
@@ -357,7 +496,6 @@ var ASSET_CONTENT_TYPES = /* @__PURE__ */ new Set([
357
496
  "image/jpeg",
358
497
  "image/gif",
359
498
  "image/webp",
360
- "image/svg+xml",
361
499
  "application/pdf"
362
500
  ]);
363
501
  var HEX_RGB = /^#[0-9a-f]{3}$/;
@@ -474,8 +612,11 @@ function assertSectionContent(kind, content) {
474
612
  }
475
613
  function safeFileName(name) {
476
614
  if (typeof name !== "string") throw new BrandInputError("file name must be a string");
477
- const cleaned = name.replace(/[/\\]/g, "").replace(/[\u0000-\u001f\u007f]/g, "").trim().replace(/^\.+/, "");
478
- if (cleaned === "") throw new BrandInputError("file name is empty after sanitizing");
615
+ if (/[/\\?#%\u0000-\u001f\u007f]/.test(name))
616
+ throw new BrandInputError("file name must not contain path or URL delimiter characters");
617
+ const cleaned = name.trim().replace(/^\.+/, "");
618
+ if (cleaned === "" || cleaned === "." || cleaned === "..")
619
+ throw new BrandInputError("file name is empty or a dot segment after sanitizing");
479
620
  return cleaned.slice(0, 120);
480
621
  }
481
622
  function assertAssetContentType(value) {
@@ -500,6 +641,237 @@ function assertAnalysis(value) {
500
641
  };
501
642
  }
502
643
 
644
+ // src/review-json.ts
645
+ var record = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
646
+ function isBoundedBrandJson(root, limits = {}) {
647
+ const maxDepth = limits.maxDepth ?? 12;
648
+ const maxNodes = limits.maxNodes ?? 2048;
649
+ const maxBytes = limits.maxBytes ?? 32 * 1024;
650
+ const stack = [{
651
+ value: root,
652
+ depth: 0
653
+ }];
654
+ const seen = /* @__PURE__ */ new Set();
655
+ let nodes = 0;
656
+ while (stack.length) {
657
+ const { value, depth } = stack.pop();
658
+ if (++nodes > maxNodes || depth > maxDepth) return false;
659
+ if (value === null || typeof value === "string" || typeof value === "boolean") continue;
660
+ if (typeof value === "number") {
661
+ if (!Number.isFinite(value)) return false;
662
+ continue;
663
+ }
664
+ if (typeof value !== "object" || seen.has(value)) return false;
665
+ seen.add(value);
666
+ if (Array.isArray(value)) {
667
+ for (const child of value)
668
+ stack.push({ value: child, depth: depth + 1 });
669
+ } else if (record(value)) {
670
+ for (const child of Object.values(value))
671
+ stack.push({ value: child, depth: depth + 1 });
672
+ } else {
673
+ return false;
674
+ }
675
+ }
676
+ try {
677
+ return new TextEncoder().encode(JSON.stringify(root)).byteLength <= maxBytes;
678
+ } catch {
679
+ return false;
680
+ }
681
+ }
682
+ function canonical(value) {
683
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
684
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
685
+ const row = value;
686
+ return `{${Object.keys(row).sort().map((key) => `${JSON.stringify(key)}:${canonical(row[key])}`).join(",")}}`;
687
+ }
688
+ function canonicalBrandJson(value) {
689
+ if (!isBoundedBrandJson(value, {
690
+ maxDepth: 20,
691
+ maxNodes: 8192,
692
+ maxBytes: 128 * 1024
693
+ })) throw new TypeError("brand digest input must be bounded finite JSON");
694
+ return canonical(value);
695
+ }
696
+ async function brandJsonDigest(value) {
697
+ const bytes = await crypto.subtle.digest(
698
+ "SHA-256",
699
+ new TextEncoder().encode(canonicalBrandJson(value))
700
+ );
701
+ return `sha256:${[...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
702
+ }
703
+
704
+ // src/review.ts
705
+ var DIGEST = /^sha256:[0-9a-f]{64}$/;
706
+ var record2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
707
+ var exactKeys = (value, required, optional = []) => {
708
+ const allowed = /* @__PURE__ */ new Set([...required, ...optional]);
709
+ return required.every((key) => Object.hasOwn(value, key)) && Object.keys(value).every((key) => allowed.has(key));
710
+ };
711
+ var boundedString = (value, max = 200) => typeof value === "string" && value.length > 0 && value.length <= max;
712
+ var safeTime = (value) => Number.isSafeInteger(value) && value >= 0;
713
+ function brandAuthorityRef(consumption) {
714
+ return `authority:${consumption.grantId}:v${consumption.grantVersion}`;
715
+ }
716
+ var SNAPSHOT_REQUIRED = [
717
+ "id",
718
+ "bookId",
719
+ "kind",
720
+ "status",
721
+ "payload",
722
+ "rationale",
723
+ "provenance",
724
+ "reviewDigest",
725
+ "audience",
726
+ "createdBy",
727
+ "createdAuthorityRef",
728
+ "createdAt"
729
+ ];
730
+ function validSnapshotShape(value) {
731
+ if (!record2(value) || !exactKeys(value, SNAPSHOT_REQUIRED)) return false;
732
+ const provenance = value.provenance;
733
+ return boundedString(value.id) && boundedString(value.bookId) && typeof value.kind === "string" && PROPOSAL_KINDS.includes(value.kind) && value.status === "open" && record2(value.payload) && boundedString(value.rationale, 2e3) && record2(provenance) && exactKeys(provenance, [
734
+ "sourceAssetId",
735
+ "sourceAsset",
736
+ "messageId",
737
+ "turnId",
738
+ "taintLabels"
739
+ ]) && (provenance.sourceAssetId === null || boundedString(provenance.sourceAssetId)) && (provenance.sourceAsset === null || record2(provenance.sourceAsset) && exactKeys(provenance.sourceAsset, [
740
+ "assetId",
741
+ "contentDigest",
742
+ "objectEtag",
743
+ "objectSize",
744
+ "pathDigest",
745
+ "contentType",
746
+ "analysisRevision",
747
+ "analysisDigest"
748
+ ]) && boundedString(provenance.sourceAsset.assetId) && typeof provenance.sourceAsset.contentDigest === "string" && DIGEST.test(provenance.sourceAsset.contentDigest) && boundedString(provenance.sourceAsset.objectEtag) && Number.isSafeInteger(provenance.sourceAsset.objectSize) && provenance.sourceAsset.objectSize > 0 && typeof provenance.sourceAsset.pathDigest === "string" && DIGEST.test(provenance.sourceAsset.pathDigest) && (provenance.sourceAsset.contentType === null || boundedString(provenance.sourceAsset.contentType, 160)) && (provenance.sourceAsset.analysisRevision === null || Number.isSafeInteger(provenance.sourceAsset.analysisRevision) && provenance.sourceAsset.analysisRevision >= 0) && (provenance.sourceAsset.analysisDigest === null || typeof provenance.sourceAsset.analysisDigest === "string" && DIGEST.test(provenance.sourceAsset.analysisDigest))) && (provenance.sourceAssetId === null && provenance.sourceAsset === null || provenance.sourceAssetId !== null && provenance.sourceAsset !== null && provenance.sourceAsset.assetId === provenance.sourceAssetId) && (provenance.messageId === null || boundedString(provenance.messageId)) && (provenance.turnId === null || boundedString(provenance.turnId)) && Array.isArray(provenance.taintLabels) && provenance.taintLabels.length <= 16 && provenance.taintLabels.every((label) => boundedString(label, 120)) && new Set(provenance.taintLabels).size === provenance.taintLabels.length && typeof value.reviewDigest === "string" && DIGEST.test(value.reviewDigest) && Array.isArray(value.audience) && value.audience.length > 0 && value.audience.length <= 100 && value.audience.every((id) => boundedString(id)) && new Set(value.audience).size === value.audience.length && boundedString(value.createdBy) && boundedString(value.createdAuthorityRef) && safeTime(value.createdAt);
749
+ }
750
+ var CONSUMPTION_REQUIRED = [
751
+ "id",
752
+ "grantId",
753
+ "grantVersion",
754
+ "useNumber",
755
+ "actorPrincipalId",
756
+ "actorKind",
757
+ "credentialId",
758
+ "credentialKind",
759
+ "appId",
760
+ "appIncarnation",
761
+ "capability",
762
+ "projectCapability",
763
+ "effect",
764
+ "actionDigest",
765
+ "resourceDigest",
766
+ "constraintEvidence",
767
+ "consumptionIdempotencyKey",
768
+ "requestDigest",
769
+ "consumedAt"
770
+ ];
771
+ function isBrandHumanAuthorityConsumption(value) {
772
+ if (!record2(value) || !exactKeys(value, CONSUMPTION_REQUIRED)) return false;
773
+ return boundedString(value.id) && boundedString(value.grantId) && Number.isSafeInteger(value.grantVersion) && value.grantVersion > 0 && Number.isSafeInteger(value.useNumber) && value.useNumber > 0 && boundedString(value.actorPrincipalId) && value.actorKind === "human" && boundedString(value.credentialId) && value.credentialKind === "clerk" && boundedString(value.appId) && typeof value.appIncarnation === "string" && /^[a-f0-9]{32}$/.test(value.appIncarnation) && value.capability === "brand.proposal.resolve" && value.projectCapability === "brand.approve" && value.effect === "internal" && typeof value.actionDigest === "string" && DIGEST.test(value.actionDigest) && typeof value.resourceDigest === "string" && DIGEST.test(value.resourceDigest) && record2(value.constraintEvidence) && boundedString(value.consumptionIdempotencyKey) && typeof value.requestDigest === "string" && DIGEST.test(value.requestDigest) && safeTime(value.consumedAt);
774
+ }
775
+ var RECEIPT_REQUIRED = [
776
+ "version",
777
+ "id",
778
+ "mutationKey",
779
+ "bookId",
780
+ "proposalId",
781
+ "resolution",
782
+ "reviewedProposal",
783
+ "actionDigest",
784
+ "decisionBinding",
785
+ "approvedBy",
786
+ "approvedByKind",
787
+ "appliedBy",
788
+ "appliedByKind",
789
+ "authorityRef",
790
+ "authorityCapability",
791
+ "authorityConsumption",
792
+ "createdAt",
793
+ "receiptDigest"
794
+ ];
795
+ async function verifyBrandApprovalReceipt(receipt2) {
796
+ try {
797
+ if (!isBoundedBrandJson(receipt2, { maxDepth: 14, maxNodes: 2500, maxBytes: 48 * 1024 }))
798
+ return false;
799
+ if (!record2(receipt2) || !exactKeys(receipt2, RECEIPT_REQUIRED, ["paletteId", "resolutionNote"]))
800
+ return false;
801
+ const binding = receipt2.decisionBinding;
802
+ if (receipt2.version !== 1 || !boundedString(receipt2.id) || !boundedString(receipt2.mutationKey) || !boundedString(receipt2.bookId) || !boundedString(receipt2.proposalId) || receipt2.resolution !== "accepted" && receipt2.resolution !== "rejected" || !validSnapshotShape(receipt2.reviewedProposal) || typeof receipt2.actionDigest !== "string" || !DIGEST.test(receipt2.actionDigest) || !record2(binding) || !exactKeys(binding, [
803
+ "version",
804
+ "bookVersion",
805
+ "activationRevision",
806
+ "activePaletteId",
807
+ "memberIdsDigest",
808
+ "activePaletteDigest",
809
+ "typographyDigest",
810
+ "sourceAssetDigest",
811
+ "effectDigest"
812
+ ]) || binding.version !== 1 || !Number.isSafeInteger(binding.bookVersion) || binding.bookVersion < 1 || !Number.isSafeInteger(binding.activationRevision) || binding.activationRevision < 0 || binding.activePaletteId !== null && !boundedString(binding.activePaletteId) || typeof binding.memberIdsDigest !== "string" || !DIGEST.test(binding.memberIdsDigest) || binding.activePaletteDigest !== null && (typeof binding.activePaletteDigest !== "string" || !DIGEST.test(binding.activePaletteDigest)) || binding.typographyDigest !== null && (typeof binding.typographyDigest !== "string" || !DIGEST.test(binding.typographyDigest)) || binding.sourceAssetDigest !== null && (typeof binding.sourceAssetDigest !== "string" || !DIGEST.test(binding.sourceAssetDigest)) || typeof binding.effectDigest !== "string" || !DIGEST.test(binding.effectDigest) || !boundedString(receipt2.approvedBy) || receipt2.approvedByKind !== "human" || !boundedString(receipt2.appliedBy) || receipt2.appliedByKind !== "human" || !boundedString(receipt2.authorityRef) || receipt2.authorityCapability !== "brand.approve" || !isBrandHumanAuthorityConsumption(receipt2.authorityConsumption) || !safeTime(receipt2.createdAt) || typeof receipt2.receiptDigest !== "string" || !DIGEST.test(receipt2.receiptDigest) || receipt2.paletteId !== void 0 && !boundedString(receipt2.paletteId) || receipt2.resolutionNote !== void 0 && !boundedString(receipt2.resolutionNote, 1e3)) return false;
813
+ const reviewed = receipt2.reviewedProposal;
814
+ const authority = receipt2.authorityConsumption;
815
+ if (reviewed.id !== receipt2.proposalId || reviewed.bookId !== receipt2.bookId || receipt2.approvedBy !== receipt2.appliedBy || authority.actorPrincipalId !== receipt2.approvedBy || authority.actionDigest !== receipt2.actionDigest || authority.consumptionIdempotencyKey !== receipt2.mutationKey || receipt2.authorityRef !== brandAuthorityRef(authority) || authority.consumedAt > receipt2.createdAt || receipt2.resolution === "rejected" && receipt2.paletteId !== void 0 || receipt2.resolution === "accepted" && reviewed.kind === "palette" && !receipt2.paletteId || reviewed.kind !== "palette" && receipt2.paletteId !== void 0) return false;
816
+ const { reviewDigest, ...unsignedReview } = reviewed;
817
+ if (await brandJsonDigest(unsignedReview) !== reviewDigest) return false;
818
+ const expectedAction = await brandJsonDigest({
819
+ version: 1,
820
+ reviewedProposal: reviewed,
821
+ resolution: receipt2.resolution,
822
+ resolutionNote: receipt2.resolutionNote ?? null,
823
+ paletteId: receipt2.paletteId ?? null,
824
+ decisionBinding: binding
825
+ });
826
+ if (expectedAction !== receipt2.actionDigest) return false;
827
+ if (await brandJsonDigest({ bookId: receipt2.bookId, proposalId: receipt2.proposalId }) !== authority.resourceDigest) return false;
828
+ const { receiptDigest, ...immutable } = receipt2;
829
+ return await brandJsonDigest(immutable) === receiptDigest;
830
+ } catch {
831
+ return false;
832
+ }
833
+ }
834
+
835
+ // src/discussion-reference.ts
836
+ var BRAND_DISCUSSION_REFERENCE_KINDS = [
837
+ "brand:book",
838
+ "brand:asset",
839
+ "brand:palette",
840
+ "brand:proposal",
841
+ "brand:receipt"
842
+ ];
843
+ var SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,199}$/;
844
+ function brandDiscussionReferenceId(target) {
845
+ return target.resourceId ? `${target.bookId}/${target.resourceId}` : target.bookId;
846
+ }
847
+ function formatBrandDiscussionReference(target) {
848
+ return `${target.kind}/${brandDiscussionReferenceId(target)}`;
849
+ }
850
+ function parseBrandDiscussionReference(input) {
851
+ let raw;
852
+ try {
853
+ raw = input instanceof URL ? input.searchParams.get("odla-ref") : new URL(input, "https://brand.invalid").searchParams.get("odla-ref");
854
+ } catch {
855
+ return null;
856
+ }
857
+ if (!raw) return null;
858
+ const [kind, bookId, resourceId, extra] = raw.split("/");
859
+ if (extra !== void 0 || !BRAND_DISCUSSION_REFERENCE_KINDS.includes(
860
+ kind
861
+ ) || !bookId || !SEGMENT.test(bookId)) return null;
862
+ const typedKind = kind;
863
+ if (typedKind === "brand:book") {
864
+ return resourceId === void 0 ? { kind: typedKind, bookId } : null;
865
+ }
866
+ return resourceId && SEGMENT.test(resourceId) ? { kind: typedKind, bookId, resourceId } : null;
867
+ }
868
+ function brandDiscussionReferenceHref(current, target, basePath = "/") {
869
+ const next = new URL(current.origin);
870
+ next.pathname = basePath.startsWith("/") ? basePath : `/${basePath}`;
871
+ next.searchParams.set("odla-ref", formatBrandDiscussionReference(target));
872
+ return next.toString();
873
+ }
874
+
503
875
  // src/ops/books.ts
504
876
  var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
505
877
  function createBookOps(input) {
@@ -507,7 +879,11 @@ function createBookOps(input) {
507
879
  if (!SLUG_RE.test(slug))
508
880
  throw new BrandInputError("slug must be lowercase letters, digits, and inner hyphens");
509
881
  const name = capString(input.name, "name", 120);
510
- const memberIds = Array.from(/* @__PURE__ */ new Set([input.ownerId, ...input.memberIds]));
882
+ const ownerId = capString(input.ownerId, "ownerId", 160);
883
+ const createdAuthorityRef = capString(input.createdAuthorityRef, "createdAuthorityRef", 200);
884
+ const memberIds = Array.from(/* @__PURE__ */ new Set([ownerId, ...input.memberIds.map((id, index) => capString(id, `memberIds[${index}]`, 160))]));
885
+ if (memberIds.length > 100)
886
+ throw new BrandInputError("a brand book supports at most 100 members");
511
887
  return [
512
888
  {
513
889
  t: "update",
@@ -515,11 +891,14 @@ function createBookOps(input) {
515
891
  id: input.id,
516
892
  attrs: {
517
893
  id: input.id,
894
+ version: 1,
518
895
  slug,
519
896
  name,
520
897
  status: "draft",
521
- ownerId: input.ownerId,
898
+ ownerId,
899
+ createdAuthorityRef,
522
900
  memberIds,
901
+ activationRevision: 0,
523
902
  createdAt: input.now,
524
903
  updatedAt: input.now,
525
904
  ...input.channelId ? { channelId: input.channelId } : {}
@@ -527,8 +906,8 @@ function createBookOps(input) {
527
906
  }
528
907
  ];
529
908
  }
530
- var PATCHABLE = /* @__PURE__ */ new Set(["name", "status", "channelId", "summary", "activePaletteId", "tokens"]);
531
- var CLEARABLE = /* @__PURE__ */ new Set(["channelId", "summary", "activePaletteId", "tokens"]);
909
+ var PATCHABLE = /* @__PURE__ */ new Set(["name", "status", "channelId", "summary"]);
910
+ var CLEARABLE = /* @__PURE__ */ new Set(["channelId", "summary"]);
532
911
  function validateBookField(key, value) {
533
912
  switch (key) {
534
913
  case "name":
@@ -541,13 +920,8 @@ function validateBookField(key, value) {
541
920
  return capString(value, "channelId", 128);
542
921
  case "summary":
543
922
  return capString(value, "summary", 2e3);
544
- case "activePaletteId":
545
- return capString(value, "activePaletteId", 128);
546
- default: {
547
- if (typeof value !== "object" || value === null || Array.isArray(value))
548
- throw new BrandInputError("tokens must be an object");
923
+ default:
549
924
  return value;
550
- }
551
925
  }
552
926
  }
553
927
  function updateBookOps(bookId, patch, now) {
@@ -594,6 +968,7 @@ function upsertSectionOps(input) {
594
968
  if (!SECTION_STATUSES.includes(status))
595
969
  throw new BrandInputError(`status must be one of: ${SECTION_STATUSES.join(", ")}`);
596
970
  const content = assertSectionContent(input.kind, input.content);
971
+ if (status === "approved" && (!input.approvalReceiptId || !input.approvalActionDigest)) throw new BrandInputError("approved sections require approval receipt provenance");
597
972
  const key = sectionKey(input.bookId, input.kind);
598
973
  return [
599
974
  {
@@ -608,47 +983,105 @@ function upsertSectionOps(input) {
608
983
  content,
609
984
  audience: input.audience,
610
985
  updatedBy: input.updatedBy,
611
- updatedAt: input.now
986
+ updatedAt: input.now,
987
+ ...input.approvalReceiptId ? { approvalReceiptId: capString(input.approvalReceiptId, "approvalReceiptId", 200) } : {},
988
+ ...input.approvalActionDigest ? { approvalActionDigest: capString(input.approvalActionDigest, "approvalActionDigest", 80) } : {}
612
989
  }
990
+ },
991
+ {
992
+ t: "link",
993
+ ns: BRAND_NS.section,
994
+ id: { ns: BRAND_NS.section, attr: "key", value: key },
995
+ label: "book",
996
+ target: input.bookId
613
997
  }
614
998
  ];
615
999
  }
1000
+ async function proposeSectionOps(input) {
1001
+ if (input.taintLabels.length > 16)
1002
+ throw new BrandInputError("taintLabels must have at most 16 entries");
1003
+ const proposal = {
1004
+ id: input.id,
1005
+ bookId: input.bookId,
1006
+ kind: input.kind,
1007
+ status: "open",
1008
+ payload: { content: assertSectionContent(input.kind, input.content) },
1009
+ rationale: capString(input.rationale, "rationale", 2e3),
1010
+ provenance: {
1011
+ sourceAssetId: input.sourceAsset?.assetId ?? null,
1012
+ sourceAsset: input.sourceAsset ?? null,
1013
+ messageId: capString(input.messageId, "messageId", 200),
1014
+ turnId: capString(input.turnId, "turnId", 200),
1015
+ taintLabels: [...new Set(input.taintLabels.map((label, index) => capString(label, `taintLabels[${index}]`, 120)))].sort()
1016
+ },
1017
+ audience: input.audience,
1018
+ createdBy: input.createdBy,
1019
+ createdAuthorityRef: input.createdAuthorityRef,
1020
+ createdAt: input.now
1021
+ };
1022
+ return [
1023
+ {
1024
+ t: "update",
1025
+ ns: BRAND_NS.proposal,
1026
+ id: input.id,
1027
+ attrs: { ...proposal, reviewDigest: await brandJsonDigest(proposal) }
1028
+ },
1029
+ { t: "link", ns: BRAND_NS.proposal, id: input.id, label: "book", target: input.bookId }
1030
+ ];
1031
+ }
616
1032
 
617
1033
  // src/ops/palettes.ts
618
- function proposePaletteOps(input) {
1034
+ async function proposePaletteOps(input) {
619
1035
  const name = capString(input.name, "name", 120);
620
1036
  const rationale = capString(input.rationale, "rationale", 2e3);
621
1037
  const swatches = assertSwatches(input.swatches);
622
1038
  const seedHex = input.seedHex === void 0 ? void 0 : assertHex(input.seedHex, "seedHex");
1039
+ if (input.taintLabels.length > 16)
1040
+ throw new BrandInputError("taintLabels must have at most 16 entries");
623
1041
  const payload = {
624
1042
  name,
625
1043
  swatches,
626
1044
  ...seedHex ? { seedHex } : {},
627
1045
  ...input.contrastReport !== void 0 ? { contrastReport: input.contrastReport } : {}
628
1046
  };
1047
+ const proposal = {
1048
+ id: input.id,
1049
+ bookId: input.bookId,
1050
+ kind: "palette",
1051
+ status: "open",
1052
+ payload,
1053
+ rationale,
1054
+ provenance: {
1055
+ sourceAssetId: input.sourceAsset?.assetId ?? null,
1056
+ sourceAsset: input.sourceAsset ?? null,
1057
+ messageId: capString(input.messageId, "messageId", 200),
1058
+ turnId: capString(input.turnId, "turnId", 200),
1059
+ taintLabels: [...new Set(input.taintLabels.map((label, index) => capString(label, `taintLabels[${index}]`, 120)))].sort()
1060
+ },
1061
+ audience: input.audience,
1062
+ createdBy: input.createdBy,
1063
+ createdAuthorityRef: input.createdAuthorityRef,
1064
+ createdAt: input.now
1065
+ };
629
1066
  return [
630
1067
  {
631
1068
  t: "update",
632
1069
  ns: BRAND_NS.proposal,
633
1070
  id: input.id,
634
- attrs: {
635
- id: input.id,
636
- bookId: input.bookId,
637
- kind: "palette",
638
- status: "open",
639
- payload,
640
- rationale,
641
- audience: input.audience,
642
- createdBy: input.createdBy,
643
- createdAt: input.now,
644
- ...input.sourceAssetId ? { sourceAssetId: input.sourceAssetId } : {},
645
- ...input.messageId ? { messageId: input.messageId } : {}
646
- }
647
- }
1071
+ attrs: { ...proposal, reviewDigest: await brandJsonDigest(proposal) }
1072
+ },
1073
+ { t: "link", ns: BRAND_NS.proposal, id: input.id, label: "book", target: input.bookId }
648
1074
  ];
649
1075
  }
650
1076
  function acceptProposalOps(input) {
651
- const { proposal, paletteId, resolvedBy, now } = input;
1077
+ const {
1078
+ proposal,
1079
+ paletteId,
1080
+ resolvedBy,
1081
+ approvalReceiptId,
1082
+ approvalActionDigest,
1083
+ now
1084
+ } = input;
652
1085
  if (proposal.kind !== "palette")
653
1086
  throw new BrandInputError(`proposal ${proposal.id} is a ${proposal.kind} proposal, not a palette`);
654
1087
  if (proposal.status !== "open")
@@ -657,7 +1090,7 @@ function acceptProposalOps(input) {
657
1090
  const name = capString(payload.name, "payload.name", 120);
658
1091
  const swatches = assertSwatches(payload.swatches);
659
1092
  const seedHex = payload.seedHex === void 0 ? void 0 : assertHex(payload.seedHex, "payload.seedHex");
660
- const source = proposal.sourceAssetId ? "extracted" : seedHex ? "derived" : "manual";
1093
+ const source = proposal.provenance.sourceAsset ? "extracted" : seedHex ? "derived" : "manual";
661
1094
  return [
662
1095
  {
663
1096
  t: "update",
@@ -672,12 +1105,19 @@ function acceptProposalOps(input) {
672
1105
  source,
673
1106
  rationale: proposal.rationale,
674
1107
  proposalId: proposal.id,
1108
+ approvalReceiptId: capString(approvalReceiptId, "approvalReceiptId", 200),
1109
+ approvalActionDigest: capString(
1110
+ approvalActionDigest,
1111
+ "approvalActionDigest",
1112
+ 80
1113
+ ),
675
1114
  audience: proposal.audience,
676
1115
  createdAt: now,
677
1116
  updatedAt: now,
678
1117
  ...seedHex ? { seedHex } : {}
679
1118
  }
680
1119
  },
1120
+ { t: "link", ns: BRAND_NS.palette, id: paletteId, label: "book", target: proposal.bookId },
681
1121
  ...upsertSectionOps({
682
1122
  bookId: proposal.bookId,
683
1123
  kind: "palette",
@@ -685,6 +1125,8 @@ function acceptProposalOps(input) {
685
1125
  status: "approved",
686
1126
  audience: proposal.audience,
687
1127
  updatedBy: resolvedBy,
1128
+ approvalReceiptId,
1129
+ approvalActionDigest,
688
1130
  now
689
1131
  }),
690
1132
  {
@@ -732,6 +1174,8 @@ function createAssetOps(input) {
732
1174
  const contentType = assertAssetContentType(input.contentType);
733
1175
  if (typeof input.size !== "number" || !Number.isFinite(input.size) || input.size <= 0)
734
1176
  throw new BrandInputError("size must be a positive byte count");
1177
+ if (!/^sha256:[0-9a-f]{64}$/.test(input.contentDigest))
1178
+ throw new BrandInputError("contentDigest must be a SHA-256 digest");
735
1179
  const title = input.title === void 0 ? void 0 : capString(input.title, "title", 160);
736
1180
  return [
737
1181
  {
@@ -743,27 +1187,68 @@ function createAssetOps(input) {
743
1187
  bookId: input.bookId,
744
1188
  kind: input.kind,
745
1189
  path: capString(input.path, "path", 512),
746
- url: capString(input.url, "url", 1024),
1190
+ storageObjectId: capString(input.storageObjectId, "storageObjectId", 200),
1191
+ contentDigest: capString(input.contentDigest, "contentDigest", 80),
747
1192
  contentType,
748
1193
  size: input.size,
1194
+ status: "live",
1195
+ analysisRevision: 0,
749
1196
  audience: input.audience,
750
1197
  uploadedBy: input.uploadedBy,
1198
+ uploadedAuthorityRef: capString(
1199
+ input.uploadedAuthorityRef,
1200
+ "uploadedAuthorityRef",
1201
+ 200
1202
+ ),
751
1203
  createdAt: input.now,
752
1204
  ...title ? { title } : {}
753
1205
  }
754
- }
1206
+ },
1207
+ { t: "link", ns: BRAND_NS.asset, id: input.id, label: "book", target: input.bookId }
755
1208
  ];
756
1209
  }
757
- function tombstoneAssetOps(assetId, now) {
758
- return [{ t: "update", ns: BRAND_NS.asset, id: assetId, attrs: { deletedAt: now } }];
1210
+ function beginAssetDeleteOps(assetId, now, deletedBy, deletedAuthorityRef) {
1211
+ return [{
1212
+ t: "update",
1213
+ ns: BRAND_NS.asset,
1214
+ id: assetId,
1215
+ attrs: {
1216
+ status: "deleting",
1217
+ deletedAt: now,
1218
+ deletedBy: capString(deletedBy, "deletedBy", 160),
1219
+ deletedAuthorityRef: capString(deletedAuthorityRef, "deletedAuthorityRef", 200)
1220
+ }
1221
+ }];
1222
+ }
1223
+ function finishAssetDeleteOps(assetId) {
1224
+ return [{
1225
+ t: "update",
1226
+ ns: BRAND_NS.asset,
1227
+ id: assetId,
1228
+ attrs: { status: "deleted" }
1229
+ }];
759
1230
  }
760
- function recordAnalysisOps(assetId, analysis, now) {
1231
+ async function recordAnalysisOps(assetId, analysis, priorRevision, analyzedBy, analyzedAuthorityRef, now) {
1232
+ if (!Number.isSafeInteger(priorRevision) || priorRevision < 0)
1233
+ throw new BrandInputError("prior analysis revision must be a non-negative integer");
1234
+ const normalized = assertAnalysis(analysis);
761
1235
  return [
762
1236
  {
763
1237
  t: "update",
764
1238
  ns: BRAND_NS.asset,
765
1239
  id: assetId,
766
- attrs: { analysis: assertAnalysis(analysis), analyzedAt: now }
1240
+ attrs: {
1241
+ analysis: normalized,
1242
+ analysisRevision: priorRevision + 1,
1243
+ analysisDigest: await brandJsonDigest(normalized),
1244
+ analyzedBy: capString(analyzedBy, "analyzedBy", 160),
1245
+ analyzedAuthorityRef: capString(
1246
+ analyzedAuthorityRef,
1247
+ "analyzedAuthorityRef",
1248
+ 200
1249
+ ),
1250
+ analyzedAt: now
1251
+ }
767
1252
  }
768
1253
  ];
769
1254
  }
@@ -1757,11 +2242,15 @@ function viewOutput(asset, bytes, contentType) {
1757
2242
  }
1758
2243
  function assetTools(ctx) {
1759
2244
  const loadAsset = async (assetId) => {
2245
+ const book = await ctx.loadBook();
1760
2246
  const res = await ctx.db.query({
1761
- [BRAND_NS.asset]: { $: { where: { id: assetId, bookId: ctx.bookId } } }
2247
+ [BRAND_NS.asset]: {
2248
+ $: { where: { id: assetId, bookId: ctx.bookId } },
2249
+ book: {}
2250
+ }
1762
2251
  });
1763
2252
  const row = (res[BRAND_NS.asset] ?? [])[0];
1764
- if (!row || row.deletedAt) throw new BrandNotFoundError(`asset ${assetId}`);
2253
+ if (!row || row.status !== "live" || row.deletedAt || !Array.isArray(row.book) || row.book.length !== 1 || row.book[0]?.id !== book.id) throw new BrandNotFoundError(`asset ${assetId}`);
1765
2254
  return row;
1766
2255
  };
1767
2256
  const viewAsset = {
@@ -1780,8 +2269,8 @@ function assetTools(ctx) {
1780
2269
  content: `This model cannot take images inside tool results. Asset ${asset.id} must be attached as a pre-turn image by the host instead \u2014 ask the human to re-send their message referencing the asset (the dispatcher attaches it up front), or describe it from what they tell you.`
1781
2270
  };
1782
2271
  }
1783
- const url = asset.url.startsWith("http") ? asset.url : ctx.fileBaseUrl + asset.url;
1784
- const fetched = await ctx.fetchBytes(url);
2272
+ const read = await ctx.authority("brand.read");
2273
+ const fetched = await ctx.readAssetContent(asset.id);
1785
2274
  if (fetched.bytes.byteLength > MAX_VIEW_BYTES) {
1786
2275
  return {
1787
2276
  content: `Asset ${asset.id} is ${fetched.bytes.byteLength} bytes \u2014 over the ${MAX_VIEW_BYTES}-byte (4.5 MB) in-conversation viewing cap. Ask the human for a smaller export of this file.`
@@ -1799,7 +2288,8 @@ function assetTools(ctx) {
1799
2288
  };
1800
2289
  const recordAnalysis = {
1801
2290
  name: "record_asset_analysis",
1802
- description: "Record what you observed in an asset you viewed: a description, its dominant colors as #rrggbb hex, and tags. Overwrites any prior analysis.",
2291
+ description: "Record what you observed in an asset you viewed. Uses guarded analysis revisions so concurrent agents cannot overwrite each other silently.",
2292
+ acceptsTaint: ["tool_untrusted:view_asset"],
1803
2293
  inputSchema: {
1804
2294
  type: "object",
1805
2295
  required: ["assetId", "description", "dominantColors", "tags"],
@@ -1812,12 +2302,19 @@ function assetTools(ctx) {
1812
2302
  },
1813
2303
  handler: ctx.guard(async (input) => {
1814
2304
  const asset = await loadAsset(String(input.assetId));
2305
+ await ctx.authority("brand.edit");
1815
2306
  const analysis = assertAnalysis({
1816
2307
  description: input.description,
1817
2308
  dominantColors: input.dominantColors,
1818
2309
  tags: input.tags
1819
2310
  });
1820
- await ctx.db.transact(recordAnalysisOps(asset.id, analysis, ctx.now()), { mutationId: ctx.newId() });
2311
+ const updated = await ctx.recordAssetAnalysis({
2312
+ mutationId: ctx.newId(),
2313
+ assetId: asset.id,
2314
+ analysis,
2315
+ expectedAnalysisRevision: asset.analysisRevision
2316
+ });
2317
+ if (updated.id !== asset.id || updated.bookId !== ctx.bookId || updated.status !== "live" || updated.analysisRevision !== asset.analysisRevision + 1 || updated.analyzedBy !== ctx.self.selfId) throw new BrandNotFoundError("asset analysis bridge returned invalid state");
1821
2318
  return {
1822
2319
  content: `Recorded analysis for asset ${asset.id}: ${analysis.dominantColors.length} dominant color(s), ${analysis.tags.length} tag(s).`
1823
2320
  };
@@ -1826,81 +2323,77 @@ function assetTools(ctx) {
1826
2323
  return [viewAsset, recordAnalysis];
1827
2324
  }
1828
2325
 
2326
+ // src/skill/source-asset.ts
2327
+ async function proposalSourceAssetId(ctx, book, value) {
2328
+ if (value === void 0) return void 0;
2329
+ const sourceAssetId = capString(value, "sourceAssetId", 200);
2330
+ const result = await ctx.db.query({
2331
+ [BRAND_NS.asset]: {
2332
+ $: {
2333
+ where: { id: sourceAssetId, bookId: book.id, status: "live" },
2334
+ limit: 2
2335
+ },
2336
+ book: { $: { limit: 2 } }
2337
+ }
2338
+ });
2339
+ const rows = result[BRAND_NS.asset] ?? [];
2340
+ const asset = rows.length === 1 ? rows[0] : void 0;
2341
+ if (!asset || asset.id !== sourceAssetId || asset.bookId !== book.id || asset.status !== "live" || asset.deletedAt !== void 0 || !Array.isArray(asset.book) || asset.book.length !== 1 || asset.book[0]?.id !== book.id) throw new BrandInputError(
2342
+ "sourceAssetId must be a live asset in this brand book"
2343
+ );
2344
+ return sourceAssetId;
2345
+ }
2346
+
1829
2347
  // src/skill/book-tools.ts
2348
+ var PROPOSABLE = SECTION_KINDS.filter((kind) => kind !== "palette");
1830
2349
  function bookTools(ctx) {
1831
- const updateSection = {
2350
+ return [{
1832
2351
  name: "update_section",
1833
- description: "Create or replace one brand-book section (palette, typography, voice, logo, or imagery). Content is validated per kind. Status defaults to draft; a human approves.",
2352
+ description: "Propose a typography, voice, logo, or imagery section revision for human approval. This never overwrites the live approved section.",
2353
+ acceptsTaint: ["tool_untrusted:view_asset"],
1834
2354
  inputSchema: {
1835
2355
  type: "object",
1836
- required: ["kind", "content"],
2356
+ required: ["kind", "content", "rationale"],
1837
2357
  properties: {
1838
- kind: { type: "string", enum: [...SECTION_KINDS] },
1839
- content: { type: "object", description: "The section payload for its kind." },
1840
- status: { type: "string", enum: ["draft", "approved"] }
2358
+ kind: { type: "string", enum: PROPOSABLE },
2359
+ content: { type: "object", description: "The complete proposed section payload." },
2360
+ rationale: { type: "string", description: "Why this revision should replace the live section." },
2361
+ sourceAssetId: {
2362
+ type: "string",
2363
+ description: "Optional exact live Brand asset that grounds this revision."
2364
+ }
1841
2365
  }
1842
2366
  },
1843
2367
  handler: ctx.guard(async (input) => {
2368
+ const allowed = /* @__PURE__ */ new Set(["kind", "content", "rationale", "sourceAssetId"]);
2369
+ if (Object.keys(input).some((key) => !allowed.has(key)))
2370
+ throw new BrandInputError("section proposal has unknown fields");
1844
2371
  const book = await ctx.loadBook();
1845
- const kind = String(input.kind);
1846
- const ops = upsertSectionOps({
1847
- bookId: ctx.bookId,
2372
+ await ctx.authority("brand.edit");
2373
+ const kind = input.kind;
2374
+ if (kind === "palette" || !PROPOSABLE.includes(kind))
2375
+ throw new BrandInputError(`kind must be one of: ${PROPOSABLE.join(", ")}`);
2376
+ const rationale = capString(input.rationale, "rationale", 2e3);
2377
+ const sourceAssetId = await proposalSourceAssetId(
2378
+ ctx,
2379
+ book,
2380
+ input.sourceAssetId
2381
+ );
2382
+ const proposal = await ctx.createProposal({
2383
+ mutationId: ctx.newId(),
1848
2384
  kind,
1849
- content: input.content,
1850
- status: input.status === void 0 ? void 0 : input.status,
1851
- audience: book.memberIds,
1852
- updatedBy: ctx.self.selfId,
1853
- now: ctx.now()
1854
- });
1855
- await ctx.db.transact(ops, { mutationId: ctx.newId() });
1856
- const status = input.status === void 0 ? "draft" : String(input.status);
1857
- return { content: `Saved ${kind} section as ${status} (key ${sectionKey(ctx.bookId, kind)}).` };
1858
- })
1859
- };
1860
- const compileTokens = {
1861
- name: "compile_tokens",
1862
- description: "Compile the book's palette (paletteId argument, or the active palette) plus the typography section into light/dark @odla-ai/ui tokens, cached on the book. Returns the compiler's warnings.",
1863
- inputSchema: {
1864
- type: "object",
1865
- properties: { paletteId: { type: "string", description: "Defaults to the book's active palette." } }
1866
- },
1867
- handler: ctx.guard(async (input) => {
1868
- const book = await ctx.loadBook();
1869
- const paletteId = input.paletteId === void 0 ? book.activePaletteId : capString(input.paletteId, "paletteId", 128);
1870
- if (!paletteId) {
1871
- throw new BrandInputError("no palette to compile: pass paletteId, or get a palette proposal accepted first");
1872
- }
1873
- const pres = await ctx.db.query({
1874
- [BRAND_NS.palette]: { $: { where: { id: paletteId, bookId: ctx.bookId } } }
1875
- });
1876
- const palette = (pres[BRAND_NS.palette] ?? [])[0];
1877
- if (!palette) throw new BrandNotFoundError(`palette ${paletteId}`);
1878
- const sres = await ctx.db.query({
1879
- [BRAND_NS.section]: { $: { where: { key: sectionKey(ctx.bookId, "typography") } } }
1880
- });
1881
- const typography = (sres[BRAND_NS.section] ?? [])[0];
1882
- const compiled = compileBrandTokens({
1883
- swatches: palette.swatches,
1884
- ...typography ? { typography: typography.content } : {}
2385
+ payload: {
2386
+ content: assertSectionContent(kind, input.content)
2387
+ },
2388
+ rationale,
2389
+ ...sourceAssetId ? { sourceAssetId } : {}
1885
2390
  });
1886
- const now = ctx.now();
1887
- const snapshot = {
1888
- light: compiled.light,
1889
- dark: compiled.dark,
1890
- warnings: compiled.warnings,
1891
- compiledAt: now
2391
+ if (proposal.bookId !== book.id || proposal.createdBy !== ctx.self.selfId || proposal.kind !== kind || proposal.status !== "open") throw new BrandInputError("brand proposal bridge returned an invalid proposal");
2392
+ return {
2393
+ content: `Parked ${kind} section proposal ${proposal.id}. The live section is unchanged; a human must approve this exact revision.`
1892
2394
  };
1893
- await ctx.db.transact(updateBookOps(ctx.bookId, { tokens: snapshot }, now), { mutationId: ctx.newId() });
1894
- const count = compiled.warnings.length;
1895
- const lines = [
1896
- `Compiled ${Object.keys(compiled.light).length} light + ${Object.keys(compiled.dark).length} dark tokens from palette ${paletteId}; ${count} warning(s).`,
1897
- ...compiled.warnings.slice(0, 3).map((w) => `- ${w.token}: ${w.message}${w.adjustedFrom ? ` (adjusted from ${w.adjustedFrom})` : ""}`)
1898
- ];
1899
- if (count > 3) lines.push(`\u2026 and ${count - 3} more.`);
1900
- return { content: lines.join("\n") };
1901
2395
  })
1902
- };
1903
- return [updateSection, compileTokens];
2396
+ }];
1904
2397
  }
1905
2398
 
1906
2399
  // src/skill/palette-tools.ts
@@ -2028,6 +2521,7 @@ function paletteTools(ctx) {
2028
2521
  const proposePalette = {
2029
2522
  name: "propose_palette",
2030
2523
  description: "Park a palette proposal for human review (does NOT change the brand). Pass explicit swatches, or a seedHex (and optional harmony) to derive a full palette. A WCAG contrast report is attached automatically.",
2524
+ acceptsTaint: ["tool_untrusted:view_asset"],
2031
2525
  inputSchema: {
2032
2526
  type: "object",
2033
2527
  required: ["name", "rationale"],
@@ -2036,6 +2530,10 @@ function paletteTools(ctx) {
2036
2530
  rationale: { type: "string", description: "Why these colors \u2014 cite assets, harmony, contrast." },
2037
2531
  seedHex: { type: "string" },
2038
2532
  harmony: { type: "string", enum: Object.keys(HARMONY_COMPANIONS) },
2533
+ sourceAssetId: {
2534
+ type: "string",
2535
+ description: "Optional exact asset id that grounded this palette."
2536
+ },
2039
2537
  swatches: {
2040
2538
  type: "array",
2041
2539
  items: {
@@ -2051,8 +2549,16 @@ function paletteTools(ctx) {
2051
2549
  }
2052
2550
  }
2053
2551
  },
2054
- handler: ctx.guard(async (input) => {
2552
+ handler: ctx.guard(async (input, toolCtx) => {
2055
2553
  const book = await ctx.loadBook();
2554
+ await ctx.authority("brand.edit");
2555
+ const name = capString(input.name, "name", 120);
2556
+ const rationale = capString(input.rationale, "rationale", 2e3);
2557
+ const sourceAssetId = await proposalSourceAssetId(
2558
+ ctx,
2559
+ book,
2560
+ input.sourceAssetId
2561
+ );
2056
2562
  const seedHex = input.seedHex === void 0 ? void 0 : assertHex(input.seedHex, "seedHex");
2057
2563
  let swatches;
2058
2564
  if (input.swatches !== void 0) {
@@ -2063,84 +2569,53 @@ function paletteTools(ctx) {
2063
2569
  if (input.harmony !== void 0) swatches = applyHarmony(swatches, seedHex, input.harmony);
2064
2570
  }
2065
2571
  const report = contrastReport(swatches);
2066
- const id = ctx.newId();
2067
- const ops = proposePaletteOps({
2068
- id,
2069
- bookId: ctx.bookId,
2070
- name: String(input.name),
2071
- rationale: String(input.rationale),
2072
- swatches,
2073
- seedHex,
2074
- contrastReport: report,
2075
- createdBy: ctx.self.selfId,
2076
- audience: book.memberIds,
2077
- now: ctx.now()
2572
+ const proposal = await ctx.createProposal({
2573
+ mutationId: ctx.newId(),
2574
+ kind: "palette",
2575
+ payload: {
2576
+ name,
2577
+ swatches,
2578
+ ...seedHex ? { seedHex } : {},
2579
+ contrastReport: report
2580
+ },
2581
+ rationale,
2582
+ ...sourceAssetId ? { sourceAssetId } : {}
2078
2583
  });
2079
- await ctx.db.transact(ops, { mutationId: id });
2584
+ if (proposal.bookId !== book.id || proposal.createdBy !== ctx.self.selfId || proposal.kind !== "palette" || proposal.status !== "open") throw new BrandInputError("brand proposal bridge returned an invalid proposal");
2080
2585
  const reportText = Object.entries(report).map(([k, v]) => `${k} ${r2(v)}:1`).join(", ");
2081
2586
  return {
2082
- content: `Parked palette proposal ${id} ("${String(input.name).trim()}", ${swatches.length} swatches${seedHex ? `, seeded from ${seedHex}` : ""}). Contrast: ${reportText}. Awaiting explicit human approval \u2014 call resolve_proposal only after the human confirms.`
2587
+ content: `Parked palette proposal ${proposal.id} ("${name}", ${swatches.length} swatches${seedHex ? `, seeded from ${seedHex}` : ""}). Contrast: ${reportText}. Awaiting a human decision in the brand approval surface.`
2083
2588
  };
2084
2589
  })
2085
2590
  };
2086
- const resolveProposal = {
2087
- name: "resolve_proposal",
2088
- description: "Apply the human's explicit verdict on an open proposal. ONLY call this after the human has clearly accepted or rejected in the conversation \u2014 never on your own initiative.",
2089
- inputSchema: {
2090
- type: "object",
2091
- required: ["proposalId", "resolution"],
2092
- properties: {
2093
- proposalId: { type: "string" },
2094
- resolution: { type: "string", enum: ["accepted", "rejected"] },
2095
- note: { type: "string" }
2096
- }
2097
- },
2098
- handler: ctx.guard(async (input) => {
2099
- const proposalId = String(input.proposalId);
2100
- const res = await ctx.db.query({
2101
- [BRAND_NS.proposal]: { $: { where: { id: proposalId, bookId: ctx.bookId } } }
2102
- });
2103
- const proposal = (res[BRAND_NS.proposal] ?? [])[0];
2104
- if (!proposal) throw new BrandNotFoundError(`proposal ${proposalId}`);
2105
- const note = input.note === void 0 ? void 0 : capString(input.note, "note", 1e3);
2106
- const now = ctx.now();
2107
- if (input.resolution === "accepted") {
2108
- const paletteId = ctx.newId();
2109
- const ops = acceptProposalOps({ proposal, paletteId, resolvedBy: ctx.self.selfId, now, resolutionNote: note });
2110
- await ctx.db.transact(ops, { mutationId: paletteId });
2111
- return { content: `Proposal ${proposalId} accepted \u2014 palette ${paletteId} written and set as the book's active palette.` };
2112
- }
2113
- if (input.resolution === "rejected") {
2114
- const ops = rejectProposalOps({ proposal, resolvedBy: ctx.self.selfId, now, resolutionNote: note });
2115
- await ctx.db.transact(ops, { mutationId: ctx.newId() });
2116
- return { content: `Proposal ${proposalId} rejected.` };
2117
- }
2118
- throw new BrandInputError('resolution must be "accepted" or "rejected"');
2119
- })
2120
- };
2121
- return [analyzeColor, evaluateContrast, proposePalette, resolveProposal];
2591
+ return [analyzeColor, evaluateContrast, proposePalette];
2122
2592
  }
2123
2593
 
2124
2594
  // src/skill/read-tools.ts
2125
2595
  var iso = (ms) => new Date(ms).toISOString();
2126
- function bookLines(book) {
2596
+ var shortId = (id) => id.length <= 12 ? id : `${id.slice(0, 6)}\u2026${id.slice(-4)}`;
2597
+ var principalLabel = (id, directory) => {
2598
+ const value = directory.get(id);
2599
+ return value ? `${value.displayName} [${value.kind} \xB7 ${shortId(id)}]` : `Unknown principal [${shortId(id)}]`;
2600
+ };
2601
+ function bookLines(book, directory) {
2127
2602
  const lines = [
2128
2603
  `brand book "${book.name}" (${book.slug}) \u2014 ${book.status}`,
2129
- `members: ${book.memberIds.join(", ")}`,
2604
+ `members: ${book.memberIds.map((id) => principalLabel(id, directory)).join(", ")}`,
2130
2605
  `active palette: ${book.activePaletteId ?? "(none accepted yet)"}`,
2131
2606
  book.tokens ? `tokens: compiled ${iso(book.tokens.compiledAt)} with ${book.tokens.warnings.length} warning(s)` : "tokens: (not compiled)"
2132
2607
  ];
2133
2608
  if (book.summary) lines.push(`summary: ${book.summary}`);
2134
2609
  return lines;
2135
2610
  }
2136
- function childLines(sections, palettes, proposals) {
2611
+ function childLines(sections, palettes, proposals, directory) {
2137
2612
  return [
2138
2613
  "sections:",
2139
- ...sections.length ? sections.map((s) => `- ${s.kind}: ${s.status}, updated ${iso(s.updatedAt)} by ${s.updatedBy}`) : ["- (none)"],
2614
+ ...sections.length ? sections.map((s) => `- ${s.kind}: ${s.status}, updated ${iso(s.updatedAt)} by ` + principalLabel(s.updatedBy, directory)) : ["- (none)"],
2140
2615
  "palettes:",
2141
2616
  ...palettes.length ? palettes.map((p) => `- ${p.id} "${p.name}" (${p.status}, ${p.source}, ${p.swatches.length} swatches)`) : ["- (none)"],
2142
2617
  "open proposals:",
2143
- ...proposals.length ? proposals.map((p) => `- ${p.id} (${p.kind}) by ${p.createdBy}: ${p.rationale}`) : ["- (none)"]
2618
+ ...proposals.length ? proposals.map((p) => `- ${p.id} (${p.kind}) by ${principalLabel(p.createdBy, directory)}: ${p.rationale}`) : ["- (none)"]
2144
2619
  ];
2145
2620
  }
2146
2621
  function readTools(ctx) {
@@ -2149,18 +2624,48 @@ function readTools(ctx) {
2149
2624
  description: "Read the current brand book: status, members, active palette, compiled tokens, sections, palettes, and open proposals.",
2150
2625
  inputSchema: { type: "object", properties: {} },
2151
2626
  handler: ctx.guard(async () => {
2627
+ const authorizedBook = await ctx.loadBook();
2152
2628
  const res = await ctx.db.query({
2153
- [BRAND_NS.book]: { $: { where: { id: ctx.bookId } } },
2154
- [BRAND_NS.section]: { $: { where: { bookId: ctx.bookId }, order: { updatedAt: "asc" } } },
2155
- [BRAND_NS.palette]: { $: { where: { bookId: ctx.bookId }, order: { createdAt: "asc" } } },
2156
- [BRAND_NS.proposal]: { $: { where: { bookId: ctx.bookId, status: "open" }, order: { createdAt: "asc" } } }
2629
+ [BRAND_NS.section]: {
2630
+ $: { where: { bookId: ctx.bookId }, order: { updatedAt: "asc" } },
2631
+ book: {}
2632
+ },
2633
+ [BRAND_NS.palette]: {
2634
+ $: { where: { bookId: ctx.bookId }, order: { createdAt: "asc" } },
2635
+ book: {}
2636
+ },
2637
+ [BRAND_NS.proposal]: {
2638
+ $: {
2639
+ where: { bookId: ctx.bookId, status: "open" },
2640
+ order: { createdAt: "asc" }
2641
+ },
2642
+ book: {}
2643
+ }
2157
2644
  });
2158
- const book = (res[BRAND_NS.book] ?? [])[0];
2159
- if (!book) throw new BrandNotFoundError(`brand book ${ctx.bookId}`);
2160
- const sections = res[BRAND_NS.section] ?? [];
2161
- const palettes = res[BRAND_NS.palette] ?? [];
2162
- const proposals = res[BRAND_NS.proposal] ?? [];
2163
- return { content: [...bookLines(book), ...childLines(sections, palettes, proposals)].join("\n") };
2645
+ const linked = (rows) => rows.filter((row) => row.bookId === authorizedBook.id && Array.isArray(row.book) && row.book.length === 1 && row.book[0]?.id === authorizedBook.id);
2646
+ const sections = linked(
2647
+ res[BRAND_NS.section] ?? []
2648
+ );
2649
+ const palettes = linked(
2650
+ res[BRAND_NS.palette] ?? []
2651
+ );
2652
+ const proposals = linked(
2653
+ res[BRAND_NS.proposal] ?? []
2654
+ );
2655
+ const ids = [
2656
+ ...authorizedBook.memberIds,
2657
+ ...sections.map((section) => section.updatedBy),
2658
+ ...proposals.map((proposal) => proposal.createdBy)
2659
+ ];
2660
+ const directory = new Map(
2661
+ (await ctx.resolvePrincipals(ids)).map((principal) => [principal.id, principal])
2662
+ );
2663
+ return {
2664
+ content: [
2665
+ ...bookLines(authorizedBook, directory),
2666
+ ...childLines(sections, palettes, proposals, directory)
2667
+ ].join("\n")
2668
+ };
2164
2669
  })
2165
2670
  };
2166
2671
  const listAssets = {
@@ -2173,15 +2678,21 @@ function readTools(ctx) {
2173
2678
  }
2174
2679
  },
2175
2680
  handler: ctx.guard(async (input) => {
2176
- const where = { bookId: ctx.bookId };
2681
+ await ctx.loadBook();
2682
+ const where = { bookId: ctx.bookId, status: "live" };
2177
2683
  if (input.kind !== void 0) {
2178
2684
  if (typeof input.kind !== "string" || !ASSET_KINDS.includes(input.kind)) {
2179
2685
  throw new BrandInputError(`kind must be one of: ${ASSET_KINDS.join(", ")}`);
2180
2686
  }
2181
2687
  where.kind = input.kind;
2182
2688
  }
2183
- const res = await ctx.db.query({ [BRAND_NS.asset]: { $: { where, order: { createdAt: "asc" } } } });
2184
- const rows = (res[BRAND_NS.asset] ?? []).filter((a2) => !a2.deletedAt);
2689
+ const res = await ctx.db.query({
2690
+ [BRAND_NS.asset]: {
2691
+ $: { where, order: { createdAt: "asc" } },
2692
+ book: {}
2693
+ }
2694
+ });
2695
+ const rows = (res[BRAND_NS.asset] ?? []).filter((asset) => asset.bookId === ctx.bookId && Array.isArray(asset.book) && asset.book.length === 1 && asset.book[0]?.id === ctx.bookId);
2185
2696
  if (rows.length === 0) {
2186
2697
  return { content: input.kind ? `(no ${String(input.kind)} assets uploaded yet)` : "(no assets uploaded yet)" };
2187
2698
  }
@@ -2195,29 +2706,64 @@ function readTools(ctx) {
2195
2706
  }
2196
2707
 
2197
2708
  // src/skill/skill.ts
2198
- var BRAND_INSTRUCTIONS = "You help build and maintain ONE brand book. Workflow, in order:\n1. Understand the brand first: read_brand_book and list_assets before proposing anything.\n2. Look at the real material: view_asset on logos and inspiration, then record what you saw with record_asset_analysis (description, dominant colors as hex, tags).\n3. Explore with the math tools: analyze_color and evaluate_contrast. Never invent a hex without a rationale \u2014 ground every color in an asset's dominant color, a harmony companion, or a contrast fix, and say which.\n4. propose_palette parks a proposal for review. It does NOT change the brand.\n5. WAIT for the human to explicitly accept or reject in the conversation before calling resolve_proposal \u2014 never resolve a proposal on your own initiative.\n6. After acceptance, document the rest with update_section (typography, voice, logo, imagery) and run compile_tokens.\n7. Relay compile warnings conversationally \u2014 explain what was adjusted and why, don't just paste them.";
2709
+ var BRAND_INSTRUCTIONS = "You help build and maintain ONE brand book. Workflow, in order:\n1. Understand the brand first: read_brand_book and list_assets before proposing anything.\n2. Look at the real material: view_asset on logos and inspiration, then record what you saw with record_asset_analysis (description, dominant colors as hex, tags).\n3. Explore with the math tools: analyze_color and evaluate_contrast. Never invent a hex without a rationale \u2014 ground every color in an asset's dominant color, a harmony companion, or a contrast fix, and say which.\n4. propose_palette parks a proposal for review. It does NOT change the brand.\n5. You cannot approve or resolve a proposal. Direct the human to the brand approval surface, which records a guarded receipt for their decision.\n6. update_section parks a proposal; it never overwrites an approved facet. Bind sourceAssetId when a real Brand asset grounds typography, voice, logo, or imagery. Human acceptance applies the exact reviewed change and automatically recompiles dependent tokens.\n7. Re-read the book after a decision and explain any compiler warnings conversationally.";
2199
2710
  function brandSkill(opts) {
2200
- const deps = resolveDeps({ db: opts.db, now: opts.now, newId: opts.newId, fetchBytes: opts.fetchBytes });
2711
+ if (opts.agentDbBinding.principalId !== opts.self.selfId || !opts.agentDbBinding.credentialRef) throw new BrandForbiddenError("brand db is not bound to the acting agent");
2712
+ const deps = resolveDeps({ db: opts.db, now: opts.now, newId: opts.newId });
2713
+ const authority = (capability) => Promise.resolve(opts.authorizeCapability({
2714
+ agentId: opts.self.selfId,
2715
+ bookId: opts.bookId,
2716
+ capability
2717
+ }) ?? null).then((result) => {
2718
+ if (!result || result.capability !== capability || !result.authorityRef)
2719
+ throw new BrandForbiddenError(`agent lacks ${capability} for brand book ${opts.bookId}`);
2720
+ return result;
2721
+ });
2201
2722
  const ctx = {
2202
2723
  db: deps.db,
2203
2724
  bookId: opts.bookId,
2204
2725
  self: opts.self,
2205
- fileBaseUrl: opts.fileBaseUrl,
2206
- fetchBytes: deps.fetchBytes,
2207
2726
  visionInToolResults: opts.visionInToolResults !== false,
2208
2727
  now: deps.now,
2209
2728
  newId: deps.newId,
2729
+ agentJobId: opts.agentJobId,
2730
+ createProposal: (input) => opts.agentBridge.createProposal({
2731
+ jobId: opts.agentJobId,
2732
+ bookId: opts.bookId,
2733
+ ...input
2734
+ }),
2735
+ recordAssetAnalysis: (input) => opts.agentBridge.recordAssetAnalysis({
2736
+ jobId: opts.agentJobId,
2737
+ bookId: opts.bookId,
2738
+ ...input
2739
+ }),
2740
+ readAssetContent: (assetId) => opts.agentBridge.readAssetContent({
2741
+ jobId: opts.agentJobId,
2742
+ bookId: opts.bookId,
2743
+ assetId
2744
+ }),
2745
+ resolvePrincipals: (principalIds) => {
2746
+ const ids = [...new Set(principalIds)].slice(0, 100);
2747
+ return opts.resolvePrincipals({
2748
+ requesterAgentId: opts.self.selfId,
2749
+ bookId: opts.bookId,
2750
+ principalIds: ids
2751
+ });
2752
+ },
2753
+ authority,
2210
2754
  loadBook: async () => {
2755
+ await authority("brand.read");
2211
2756
  const res = await deps.db.query({ [BRAND_NS.book]: { $: { where: { id: opts.bookId } } } });
2212
2757
  const row = (res[BRAND_NS.book] ?? [])[0];
2213
- if (!row) throw new BrandNotFoundError(`brand book ${opts.bookId}`);
2758
+ if (!row || !row.memberIds.includes(opts.self.selfId))
2759
+ throw new BrandNotFoundError(`brand book ${opts.bookId}`);
2214
2760
  return row;
2215
2761
  },
2216
2762
  guard: (handler) => async (input, toolCtx) => {
2217
2763
  try {
2218
2764
  return await handler(input, toolCtx);
2219
2765
  } catch (error) {
2220
- if (error instanceof BrandInputError || error instanceof BrandNotFoundError) {
2766
+ if (error instanceof BrandInputError || error instanceof BrandNotFoundError || error instanceof BrandForbiddenError) {
2221
2767
  return { content: error.message, isError: true };
2222
2768
  }
2223
2769
  throw error;
@@ -2232,7 +2778,7 @@ function brandSkill(opts) {
2232
2778
  }
2233
2779
 
2234
2780
  // src/skill/persona.ts
2235
- var DEFAULT_BRAND_SYSTEM = "You are a meticulous brand director for one brand book. You study the real material before forming opinions, you justify every color with math (harmony, \u0394EOK, WCAG contrast) or provenance (an asset's dominant colors), and you present options rather than dictating. Proposals are yours to make; decisions are the human's \u2014 never resolve a proposal without their explicit confirmation in this conversation. When tokens compile with warnings, explain each adjustment in plain language.";
2781
+ var DEFAULT_BRAND_SYSTEM = "You are a meticulous brand director for one brand book. You study the real material before forming opinions, you justify every color with math (harmony, \u0394EOK, WCAG contrast) or provenance (an asset's dominant colors), and you present options rather than dictating. Proposals and drafts are yours to make; decisions are the human's \u2014 you have no proposal-resolution or section-approval tool. When tokens compile with warnings, explain each adjustment in plain language.";
2236
2782
  function createBrandPersona(opts) {
2237
2783
  return {
2238
2784
  name: "brand-director",
@@ -2248,7 +2794,35 @@ function supportsBrandVision(spec) {
2248
2794
  return !!(caps?.toolResultBlocks && caps.imageIn && caps.documentIn);
2249
2795
  }
2250
2796
 
2797
+ // src/routes/asset-query.ts
2798
+ async function linkedAsset(ctx, book, assetId, allowDeleting = false) {
2799
+ const result = await ctx.db.query({
2800
+ [BRAND_NS.asset]: {
2801
+ $: { where: { id: assetId, bookId: book.id } },
2802
+ book: {}
2803
+ }
2804
+ });
2805
+ const row = (result[BRAND_NS.asset] ?? [])[0];
2806
+ const links = row?.book;
2807
+ if (!row || row.bookId !== book.id || !Array.isArray(links) || links.length !== 1 || links[0]?.id !== book.id || row.status !== "live" && !(allowDeleting && row.status === "deleting")) throw new BrandNotFoundError(`asset ${assetId}`);
2808
+ return row;
2809
+ }
2810
+
2251
2811
  // src/routes/http.ts
2812
+ async function requireRouteAuthority(ctx, req, capability, effect, bookId) {
2813
+ if (ctx.actor.kind !== "human")
2814
+ throw new BrandForbiddenError("this brand effect requires a directly authenticated human");
2815
+ const result = await ctx.authorizeCapability({
2816
+ req,
2817
+ actor: ctx.actor,
2818
+ appId: ctx.appId,
2819
+ ...bookId ? { bookId } : {},
2820
+ capability,
2821
+ effect
2822
+ });
2823
+ if (!result || result.actorPrincipalId !== ctx.actor.id || result.capability !== capability || result.effect !== effect || typeof result.authorityRef !== "string" || result.authorityRef.length < 1 || result.authorityRef.length > 200) throw new BrandForbiddenError(`human lacks ${capability} ${effect} authority`);
2824
+ return result;
2825
+ }
2252
2826
  var json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
2253
2827
  status,
2254
2828
  headers: { "content-type": "application/json", ...headers }
@@ -2256,7 +2830,12 @@ var json = (body, status = 200, headers = {}) => new Response(JSON.stringify(bod
2256
2830
  var errorResponse = (error) => {
2257
2831
  if (error instanceof BrandInputError)
2258
2832
  return json({ error: error.message, ...error.fields ? { fields: error.fields } : {} }, 400);
2833
+ if (error instanceof BrandForbiddenError) return json({ error: error.message }, 403);
2259
2834
  if (error instanceof BrandNotFoundError) return json({ error: error.message }, 404);
2835
+ if (error instanceof BrandGoneError) return json({ error: error.message }, 410);
2836
+ if (error instanceof BrandReviewStateChangedError)
2837
+ return json({ error: error.message, code: error.code }, 409);
2838
+ if (error instanceof BrandConflictError) return json({ error: error.message }, 409);
2260
2839
  return json({ error: "internal error" }, 500);
2261
2840
  };
2262
2841
  var methodNotAllowed = () => json({ error: "method not allowed" }, 405);
@@ -2305,86 +2884,217 @@ async function loadMemberBook(db, bookId, actorId) {
2305
2884
  return book;
2306
2885
  }
2307
2886
 
2308
- // src/routes/assets.ts
2887
+ // src/routes/asset-upload.ts
2309
2888
  async function readForm(req) {
2310
2889
  try {
2311
2890
  return await req.formData();
2312
2891
  } catch {
2313
- throw new BrandInputError('expected a multipart/form-data body with a "file" field');
2892
+ throw new BrandInputError(
2893
+ 'expected multipart/form-data with a "file" field'
2894
+ );
2314
2895
  }
2315
2896
  }
2897
+ async function contentDigest(file) {
2898
+ const hash = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
2899
+ return `sha256:${[...new Uint8Array(hash)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
2900
+ }
2316
2901
  async function uploadAsset(ctx, req, bookId) {
2317
2902
  const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
2318
2903
  const form = await readForm(req);
2319
2904
  const file = form.get("file");
2320
- if (!(file instanceof File)) throw new BrandInputError('"file" must be an uploaded file field');
2905
+ if (!(file instanceof File))
2906
+ throw new BrandInputError('"file" must be an uploaded file field');
2321
2907
  let contentType;
2322
2908
  try {
2323
2909
  contentType = assertAssetContentType(file.type);
2324
2910
  } catch (error) {
2325
- if (error instanceof BrandInputError) return json({ error: error.message }, 415);
2911
+ if (error instanceof BrandInputError)
2912
+ return json({ error: error.message }, 415);
2326
2913
  throw error;
2327
2914
  }
2328
2915
  if (file.size > ctx.maxUploadBytes)
2329
- return json(
2330
- { error: `file is ${file.size} bytes; the upload limit is ${ctx.maxUploadBytes}` },
2331
- 413
2332
- );
2916
+ return json({ error: `file exceeds ${ctx.maxUploadBytes} bytes` }, 413);
2333
2917
  const kindRaw = form.get("kind");
2334
- const kind = typeof kindRaw === "string" && kindRaw !== "" ? kindRaw : "other";
2918
+ const kind = typeof kindRaw === "string" && kindRaw ? kindRaw : "other";
2335
2919
  if (!ASSET_KINDS.includes(kind))
2336
2920
  throw new BrandInputError(`kind must be one of: ${ASSET_KINDS.join(", ")}`);
2337
2921
  const titleRaw = form.get("title");
2338
- const title = typeof titleRaw === "string" && titleRaw !== "" ? capString(titleRaw, "title", 160) : void 0;
2922
+ const title = typeof titleRaw === "string" && titleRaw ? capString(titleRaw, "title", 160) : void 0;
2923
+ const fileName = safeFileName(file.name);
2339
2924
  const id = ctx.newId();
2340
- const path = `brand/${book.id}/assets/${id}/${safeFileName(file.name)}`;
2341
- const record = await ctx.db.storage.upload(path, file, contentType);
2342
- await ctx.db.transact(
2343
- createAssetOps({
2925
+ const path = `brand/${book.id}/assets/${id}/${fileName}`;
2926
+ const authority = await requireRouteAuthority(
2927
+ ctx,
2928
+ req,
2929
+ "brand.edit",
2930
+ "internal",
2931
+ book.id
2932
+ );
2933
+ const record6 = await ctx.db.storage.upload(
2934
+ path,
2935
+ file,
2936
+ contentType,
2937
+ { private: true }
2938
+ );
2939
+ if (record6.path !== path) {
2940
+ await ctx.db.storage.delete(record6.path);
2941
+ throw new BrandConflictError(
2942
+ "private storage returned an unexpected asset path"
2943
+ );
2944
+ }
2945
+ try {
2946
+ const current = await requireRouteAuthority(
2947
+ ctx,
2948
+ req,
2949
+ "brand.edit",
2950
+ "internal",
2951
+ book.id
2952
+ );
2953
+ if (current.authorityRef !== authority.authorityRef)
2954
+ throw new BrandConflictError("brand edit authority changed during upload");
2955
+ await ctx.db.transact(createAssetOps({
2344
2956
  id,
2345
2957
  bookId: book.id,
2346
2958
  kind,
2347
- path: record.path,
2348
- url: record.url,
2959
+ path: record6.path,
2960
+ storageObjectId: record6.id,
2961
+ contentDigest: await contentDigest(file),
2349
2962
  contentType,
2350
- size: record.size,
2963
+ size: record6.size,
2351
2964
  uploadedBy: ctx.actor.id,
2965
+ uploadedAuthorityRef: authority.authorityRef,
2352
2966
  audience: book.memberIds,
2353
2967
  title,
2354
2968
  now: ctx.now()
2355
- }),
2356
- { mutationId: id }
2357
- );
2358
- const res = await ctx.db.query({ [BRAND_NS.asset]: { $: { where: { id } } } });
2359
- return json((res[BRAND_NS.asset] ?? [])[0], 201);
2360
- }
2361
- async function handleAssetsRoot(ctx, req, bookId) {
2362
- if (req.method === "GET") {
2363
- const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
2364
- const res = await ctx.db.query({
2365
- [BRAND_NS.asset]: {
2366
- // `deletedAt: null` matches ONLY rows without the attr — odla-db's
2367
- // "absence is a missing triple" semantics — i.e. not tombstoned.
2368
- $: { where: { bookId: book.id, deletedAt: null }, order: { createdAt: "asc" } }
2369
- }
2969
+ }), {
2970
+ mutationId: `brand:asset-upload:v1:${id}`,
2971
+ guards: [
2972
+ { ns: BRAND_NS.asset, id, exists: false },
2973
+ {
2974
+ ns: BRAND_NS.book,
2975
+ id: book.id,
2976
+ exists: true,
2977
+ equals: {
2978
+ version: book.version,
2979
+ activationRevision: book.activationRevision,
2980
+ memberIds: book.memberIds
2981
+ }
2982
+ }
2983
+ ],
2984
+ asUser: ctx.actor.id,
2985
+ ...ctx.actor.email ? { asEmail: ctx.actor.email } : {},
2986
+ asPrincipalKind: "human"
2370
2987
  });
2371
- return json({ assets: res[BRAND_NS.asset] ?? [] });
2988
+ } catch (error) {
2989
+ await ctx.db.storage.delete(record6.path);
2990
+ throw error;
2372
2991
  }
2992
+ return json(await linkedAsset(ctx, book, id), 201);
2993
+ }
2994
+
2995
+ // src/routes/assets.ts
2996
+ async function handleAssetsRoot(ctx, req, bookId) {
2373
2997
  if (req.method === "POST") return uploadAsset(ctx, req, bookId);
2374
- return methodNotAllowed();
2998
+ if (req.method !== "GET") return methodNotAllowed();
2999
+ const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
3000
+ const result = await ctx.db.query({
3001
+ [BRAND_NS.asset]: {
3002
+ $: { where: { bookId: book.id, status: "live" }, order: { createdAt: "asc" } },
3003
+ book: {}
3004
+ }
3005
+ });
3006
+ const assets = (result[BRAND_NS.asset] ?? []).filter((value) => {
3007
+ const row = value;
3008
+ return row.bookId === book.id && Array.isArray(row.book) && row.book.length === 1 && row.book[0]?.id === book.id;
3009
+ });
3010
+ return json({ assets });
3011
+ }
3012
+ async function handleAssetContent(ctx, req, bookId, assetId) {
3013
+ if (req.method !== "GET") return methodNotAllowed();
3014
+ const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
3015
+ const asset = await linkedAsset(ctx, book, assetId);
3016
+ const expiresInSeconds = 300;
3017
+ const url = await ctx.db.storage.sign(asset.path, expiresInSeconds);
3018
+ return json(
3019
+ { assetId, url, expiresInSeconds },
3020
+ 200,
3021
+ { "cache-control": "private, no-store", "x-content-type-options": "nosniff" }
3022
+ );
2375
3023
  }
2376
3024
  async function handleAssetItem(ctx, req, bookId, assetId) {
2377
3025
  if (req.method !== "DELETE") return methodNotAllowed();
2378
3026
  const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
2379
- const res = await ctx.db.query({
2380
- [BRAND_NS.asset]: { $: { where: { id: assetId, bookId: book.id } } }
2381
- });
2382
- const asset = (res[BRAND_NS.asset] ?? [])[0];
2383
- if (!asset || asset.deletedAt != null) throw new BrandNotFoundError(`asset ${assetId}`);
3027
+ let asset = await linkedAsset(ctx, book, assetId, true);
3028
+ const authority = await requireRouteAuthority(
3029
+ ctx,
3030
+ req,
3031
+ "brand.edit",
3032
+ "destructive",
3033
+ book.id
3034
+ );
3035
+ const mutationBase = `brand:asset-delete:v1:${book.id}:${asset.id}`;
3036
+ if (asset.status === "live") {
3037
+ const deletedAt = ctx.now();
3038
+ try {
3039
+ await ctx.db.transact(beginAssetDeleteOps(
3040
+ asset.id,
3041
+ deletedAt,
3042
+ ctx.actor.id,
3043
+ authority.authorityRef
3044
+ ), {
3045
+ mutationId: `${mutationBase}:begin`,
3046
+ guards: [
3047
+ {
3048
+ ns: BRAND_NS.asset,
3049
+ id: asset.id,
3050
+ exists: true,
3051
+ equals: {
3052
+ bookId: book.id,
3053
+ path: asset.path,
3054
+ storageObjectId: asset.storageObjectId,
3055
+ contentDigest: asset.contentDigest,
3056
+ status: "live"
3057
+ }
3058
+ },
3059
+ {
3060
+ ns: BRAND_NS.book,
3061
+ id: book.id,
3062
+ exists: true,
3063
+ equals: { version: book.version, memberIds: book.memberIds }
3064
+ }
3065
+ ],
3066
+ asUser: ctx.actor.id,
3067
+ ...ctx.actor.email ? { asEmail: ctx.actor.email } : {},
3068
+ asPrincipalKind: "human"
3069
+ });
3070
+ } catch (error) {
3071
+ if (typeof error === "object" && error !== null && error.code === "transact_guard_failed") throw new BrandConflictError("asset changed before deletion");
3072
+ throw error;
3073
+ }
3074
+ asset = { ...asset, status: "deleting", deletedAt };
3075
+ }
2384
3076
  await ctx.db.storage.delete(asset.path);
2385
- const deletedAt = ctx.now();
2386
- await ctx.db.transact(tombstoneAssetOps(asset.id, deletedAt));
2387
- return json({ id: asset.id, deletedAt });
3077
+ await ctx.db.transact(finishAssetDeleteOps(asset.id), {
3078
+ mutationId: `${mutationBase}:finish`,
3079
+ guards: [{
3080
+ ns: BRAND_NS.asset,
3081
+ id: asset.id,
3082
+ exists: true,
3083
+ equals: {
3084
+ bookId: book.id,
3085
+ path: asset.path,
3086
+ storageObjectId: asset.storageObjectId,
3087
+ contentDigest: asset.contentDigest,
3088
+ status: "deleting",
3089
+ deletedBy: asset.deletedBy ?? ctx.actor.id,
3090
+ deletedAuthorityRef: asset.deletedAuthorityRef ?? authority.authorityRef
3091
+ }
3092
+ }],
3093
+ asUser: ctx.actor.id,
3094
+ ...ctx.actor.email ? { asEmail: ctx.actor.email } : {},
3095
+ asPrincipalKind: "human"
3096
+ });
3097
+ return json({ id: asset.id, status: "deleted", deletedAt: asset.deletedAt });
2388
3098
  }
2389
3099
 
2390
3100
  // src/routes/books.ts
@@ -2397,73 +3107,1036 @@ async function handleBooksRoot(ctx, req) {
2397
3107
  return json({ books });
2398
3108
  }
2399
3109
  if (req.method === "POST") {
3110
+ if (ctx.actor.kind !== "human")
3111
+ throw new BrandForbiddenError("only a human can create a brand book");
2400
3112
  const body = await readJson(req);
2401
3113
  const id = ctx.newId();
3114
+ const authority = await requireRouteAuthority(
3115
+ ctx,
3116
+ req,
3117
+ "brand.edit",
3118
+ "internal"
3119
+ );
2402
3120
  const ops = createBookOps({
2403
3121
  id,
2404
3122
  slug: str(body, "slug"),
2405
3123
  name: str(body, "name"),
2406
3124
  ownerId: ctx.actor.id,
3125
+ createdAuthorityRef: authority.authorityRef,
2407
3126
  memberIds: optStrArray(body, "memberIds") ?? [],
2408
3127
  channelId: optStr(body, "channelId"),
2409
3128
  now: ctx.now()
2410
3129
  });
2411
- await ctx.db.transact(ops, { mutationId: id });
3130
+ await ctx.db.transact(ops, {
3131
+ mutationId: id,
3132
+ guards: [{ ns: BRAND_NS.book, id, exists: false }],
3133
+ asUser: ctx.actor.id,
3134
+ ...ctx.actor.email ? { asEmail: ctx.actor.email } : {},
3135
+ asPrincipalKind: "human"
3136
+ });
2412
3137
  return json(await loadBook(ctx.db, id), 201);
2413
3138
  }
2414
3139
  return methodNotAllowed();
2415
3140
  }
3141
+ var sameStrings = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);
3142
+ var guardFailure = (error) => typeof error === "object" && error !== null && error.code === "transact_guard_failed";
3143
+ async function handleBookMembers(ctx, req, bookId) {
3144
+ if (req.method !== "PATCH") return methodNotAllowed();
3145
+ const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
3146
+ if (ctx.actor.kind !== "human")
3147
+ throw new BrandForbiddenError("only a human app owner can change members");
3148
+ const body = await readJson(req);
3149
+ const rawMembers = optStrArray(body, "memberIds");
3150
+ const rawExpected = optStrArray(body, "expectedMemberIds");
3151
+ if (!rawMembers || !rawExpected)
3152
+ throw new BrandInputError('"memberIds" and "expectedMemberIds" are required string arrays');
3153
+ const memberIds = Array.from(/* @__PURE__ */ new Set([
3154
+ book.ownerId,
3155
+ ...rawMembers.map((id, index) => capString(id, `memberIds[${index}]`, 160))
3156
+ ]));
3157
+ if (memberIds.length > 100)
3158
+ throw new BrandInputError("a brand book supports at most 100 members");
3159
+ const expectedMemberIds = rawExpected.map((id, index) => capString(id, `expectedMemberIds[${index}]`, 160));
3160
+ if (!sameStrings(book.memberIds, expectedMemberIds) && !sameStrings(book.memberIds, memberIds))
3161
+ throw new BrandConflictError("brand book membership changed since review");
3162
+ const mutationId = capString(str(body, "mutationId"), "mutationId", 200);
3163
+ const mutationKey = `brand:book-members:v1:${bookId}:${mutationId}`;
3164
+ const authority = await requireRouteAuthority(
3165
+ ctx,
3166
+ req,
3167
+ "app.manage",
3168
+ "internal",
3169
+ bookId
3170
+ );
3171
+ try {
3172
+ const tx = await ctx.db.transact(
3173
+ [{
3174
+ t: "update",
3175
+ ns: BRAND_NS.book,
3176
+ id: bookId,
3177
+ attrs: {
3178
+ memberIds,
3179
+ version: book.version + 1,
3180
+ updatedAt: ctx.now(),
3181
+ rosterAuthorityRef: authority.authorityRef
3182
+ }
3183
+ }],
3184
+ {
3185
+ mutationId: mutationKey,
3186
+ guards: [{
3187
+ ns: BRAND_NS.book,
3188
+ id: bookId,
3189
+ exists: true,
3190
+ equals: { memberIds: expectedMemberIds, version: book.version }
3191
+ }],
3192
+ asUser: ctx.actor.id,
3193
+ ...ctx.actor.email ? { asEmail: ctx.actor.email } : {},
3194
+ asPrincipalKind: "human"
3195
+ }
3196
+ );
3197
+ return json({ bookId, memberIds, duplicate: tx.duplicate === true });
3198
+ } catch (error) {
3199
+ if (guardFailure(error))
3200
+ throw new BrandConflictError("brand book membership changed since review");
3201
+ throw error;
3202
+ }
3203
+ }
2416
3204
  async function handleBookItem(ctx, req, bookId) {
2417
3205
  if (req.method !== "GET") return methodNotAllowed();
2418
3206
  return json(await loadMemberBook(ctx.db, bookId, ctx.actor.id));
2419
3207
  }
2420
3208
 
3209
+ // src/routes/proposal-input.ts
3210
+ var SNAPSHOT_KEYS = [
3211
+ "audience",
3212
+ "bookId",
3213
+ "createdAt",
3214
+ "createdAuthorityRef",
3215
+ "createdBy",
3216
+ "id",
3217
+ "kind",
3218
+ "payload",
3219
+ "provenance",
3220
+ "rationale",
3221
+ "reviewDigest",
3222
+ "status"
3223
+ ];
3224
+ var record3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3225
+ function parseProposalResolutionRequest(body, bookId, proposalId) {
3226
+ const allowed = /* @__PURE__ */ new Set([
3227
+ "mutationId",
3228
+ "resolution",
3229
+ "reviewedProposal",
3230
+ "note"
3231
+ ]);
3232
+ if (Object.keys(body).some((key) => !allowed.has(key)))
3233
+ throw new BrandInputError("proposal resolution body has unknown fields");
3234
+ const mutationId = capString(str(body, "mutationId"), "mutationId", 200);
3235
+ const rawResolution = str(body, "resolution");
3236
+ if (rawResolution !== "accepted" && rawResolution !== "rejected")
3237
+ throw new BrandInputError('"resolution" must be "accepted" or "rejected"');
3238
+ const rawNote = optStr(body, "note");
3239
+ const resolutionNote = rawNote?.trim() ? capString(rawNote, "note", 1e3) : void 0;
3240
+ return {
3241
+ mutationId,
3242
+ resolution: rawResolution,
3243
+ reviewed: parseReviewedProposal(body.reviewedProposal, bookId, proposalId),
3244
+ ...resolutionNote ? { resolutionNote } : {}
3245
+ };
3246
+ }
3247
+ function proposalReviewSnapshot(proposal) {
3248
+ if (proposal.status !== "open")
3249
+ throw new BrandConflictError(`proposal ${proposal.id} is ${proposal.status}, not open`);
3250
+ return {
3251
+ id: proposal.id,
3252
+ bookId: proposal.bookId,
3253
+ kind: proposal.kind,
3254
+ status: "open",
3255
+ payload: proposal.payload,
3256
+ rationale: proposal.rationale,
3257
+ provenance: proposal.provenance,
3258
+ reviewDigest: proposal.reviewDigest,
3259
+ audience: proposal.audience,
3260
+ createdBy: proposal.createdBy,
3261
+ createdAuthorityRef: proposal.createdAuthorityRef,
3262
+ createdAt: proposal.createdAt
3263
+ };
3264
+ }
3265
+ function parseReviewedProposal(value, bookId, proposalId) {
3266
+ if (!isBoundedBrandJson(value, { maxDepth: 12, maxNodes: 1500, maxBytes: 32 * 1024 }))
3267
+ throw new BrandInputError('"reviewedProposal" is too deeply nested, complex, or large');
3268
+ if (!record3(value)) throw new BrandInputError('"reviewedProposal" must be an object');
3269
+ const keys = Object.keys(value).sort();
3270
+ if (keys.length !== SNAPSHOT_KEYS.length || !SNAPSHOT_KEYS.every((key, index) => key === keys[index])) throw new BrandInputError('"reviewedProposal" has an invalid shape');
3271
+ if (value.id !== proposalId || value.bookId !== bookId || value.status !== "open")
3272
+ throw new BrandInputError('"reviewedProposal" must identify this open proposal');
3273
+ if (typeof value.kind !== "string" || !PROPOSAL_KINDS.includes(value.kind) || !record3(value.payload) || typeof value.rationale !== "string" || !Array.isArray(value.audience) || value.audience.length < 1 || value.audience.length > 100 || value.audience.some((id) => typeof id !== "string" || id.length < 1 || id.length > 200) || new Set(value.audience).size !== value.audience.length || typeof value.createdBy !== "string" || value.createdBy.length < 1 || value.createdBy.length > 200 || typeof value.createdAuthorityRef !== "string" || value.createdAuthorityRef.length < 1 || value.createdAuthorityRef.length > 200 || !Number.isSafeInteger(value.createdAt) || value.createdAt < 0 || typeof value.reviewDigest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(value.reviewDigest)) throw new BrandInputError('"reviewedProposal" has invalid fields');
3274
+ if (!record3(value.provenance) || Object.keys(value.provenance).sort().join(",") !== "messageId,sourceAsset,sourceAssetId,taintLabels,turnId" || value.provenance.sourceAssetId !== null && (typeof value.provenance.sourceAssetId !== "string" || value.provenance.sourceAssetId.length < 1 || value.provenance.sourceAssetId.length > 200) || value.provenance.sourceAsset !== null && (!record3(value.provenance.sourceAsset) || Object.keys(value.provenance.sourceAsset).sort().join(",") !== "analysisDigest,analysisRevision,assetId,contentDigest,contentType,objectEtag,objectSize,pathDigest" || typeof value.provenance.sourceAsset.assetId !== "string" || value.provenance.sourceAsset.assetId.length < 1 || value.provenance.sourceAsset.assetId.length > 200 || typeof value.provenance.sourceAsset.objectEtag !== "string" || value.provenance.sourceAsset.objectEtag.length < 1 || value.provenance.sourceAsset.objectEtag.length > 200 || !Number.isSafeInteger(value.provenance.sourceAsset.objectSize) || value.provenance.sourceAsset.objectSize < 1 || typeof value.provenance.sourceAsset.pathDigest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(value.provenance.sourceAsset.pathDigest) || value.provenance.sourceAsset.contentType !== null && (typeof value.provenance.sourceAsset.contentType !== "string" || value.provenance.sourceAsset.contentType.length < 1 || value.provenance.sourceAsset.contentType.length > 160) || typeof value.provenance.sourceAsset.contentDigest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(value.provenance.sourceAsset.contentDigest) || value.provenance.sourceAsset.analysisRevision !== null && (!Number.isSafeInteger(value.provenance.sourceAsset.analysisRevision) || value.provenance.sourceAsset.analysisRevision < 0) || value.provenance.sourceAsset.analysisDigest !== null && (typeof value.provenance.sourceAsset.analysisDigest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(
3275
+ value.provenance.sourceAsset.analysisDigest
3276
+ )) || value.provenance.sourceAsset.assetId !== value.provenance.sourceAssetId) || value.provenance.sourceAssetId === null !== (value.provenance.sourceAsset === null) || value.provenance.messageId !== null && (typeof value.provenance.messageId !== "string" || value.provenance.messageId.length < 1 || value.provenance.messageId.length > 200) || value.provenance.turnId !== null && (typeof value.provenance.turnId !== "string" || value.provenance.turnId.length < 1 || value.provenance.turnId.length > 200) || !Array.isArray(value.provenance.taintLabels) || value.provenance.taintLabels.length > 16 || value.provenance.taintLabels.some((label) => typeof label !== "string" || label.length < 1 || label.length > 120) || new Set(value.provenance.taintLabels).size !== value.provenance.taintLabels.length) throw new BrandInputError('"reviewedProposal.provenance" is invalid');
3277
+ return value;
3278
+ }
3279
+ var proposalGuardEquals = (snapshot) => ({ ...snapshot });
3280
+
3281
+ // src/routes/proposal-effects.ts
3282
+ async function tokenSnapshot(swatches, typography, input, paletteId, paletteReceiptId, paletteActionDigest, typographyReceiptId, typographyActionDigest) {
3283
+ const compiled = compileBrandTokens({ swatches, ...typography ? { typography } : {} });
3284
+ const sources = {
3285
+ paletteId,
3286
+ paletteReceiptId,
3287
+ paletteActionDigest,
3288
+ typographyReceiptId: typographyReceiptId ?? null,
3289
+ typographyActionDigest: typographyActionDigest ?? null
3290
+ };
3291
+ return {
3292
+ light: compiled.light,
3293
+ dark: compiled.dark,
3294
+ warnings: compiled.warnings,
3295
+ compiledAt: input.now,
3296
+ paletteId,
3297
+ paletteReceiptId,
3298
+ paletteActionDigest,
3299
+ ...typographyReceiptId ? { typographyReceiptId } : {},
3300
+ ...typographyActionDigest ? { typographyActionDigest } : {},
3301
+ sourceDigest: await brandJsonDigest(sources)
3302
+ };
3303
+ }
3304
+ function stampProposal(ops, input) {
3305
+ const op = ops.find(
3306
+ (candidate) => candidate.t === "update" && candidate.ns === BRAND_NS.proposal && candidate.id === input.proposal.id
3307
+ );
3308
+ if (!op || op.t !== "update") throw new Error("proposal resolution op missing");
3309
+ Object.assign(op.attrs, {
3310
+ approvedBy: input.actorId,
3311
+ appliedBy: input.actorId,
3312
+ resolutionReceiptId: input.receiptId,
3313
+ resolutionActionDigest: input.actionDigest
3314
+ });
3315
+ }
3316
+ async function proposalDecisionOps(input) {
3317
+ const common = {
3318
+ proposal: input.proposal,
3319
+ resolvedBy: input.actorId,
3320
+ now: input.now,
3321
+ resolutionNote: input.resolutionNote
3322
+ };
3323
+ if (input.resolution === "rejected") {
3324
+ const ops2 = rejectProposalOps(common);
3325
+ stampProposal(ops2, input);
3326
+ return ops2;
3327
+ }
3328
+ let ops;
3329
+ if (input.proposal.kind === "palette") {
3330
+ if (!input.paletteId) throw new Error("accepted palette id missing");
3331
+ ops = acceptProposalOps({
3332
+ ...common,
3333
+ paletteId: input.paletteId,
3334
+ approvalReceiptId: input.receiptId,
3335
+ approvalActionDigest: input.actionDigest
3336
+ });
3337
+ const swatches = assertSwatches(input.proposal.payload.swatches);
3338
+ const tokens = await tokenSnapshot(
3339
+ swatches,
3340
+ input.approvedTypography?.content,
3341
+ input,
3342
+ input.paletteId,
3343
+ input.receiptId,
3344
+ input.actionDigest,
3345
+ input.approvedTypography?.approvalReceiptId,
3346
+ input.approvedTypography?.approvalActionDigest
3347
+ );
3348
+ const bookOp = ops.find(
3349
+ (op) => op.t === "update" && op.ns === BRAND_NS.book && op.id === input.book.id
3350
+ );
3351
+ if (!bookOp || bookOp.t !== "update") throw new Error("book activation op missing");
3352
+ Object.assign(bookOp.attrs, {
3353
+ version: input.book.version + 1,
3354
+ activationRevision: input.book.activationRevision + 1,
3355
+ tokens
3356
+ });
3357
+ if (input.priorPalette && input.priorPalette.id !== input.paletteId) {
3358
+ ops.unshift({
3359
+ t: "update",
3360
+ ns: BRAND_NS.palette,
3361
+ id: input.priorPalette.id,
3362
+ attrs: { status: "archived", updatedAt: input.now }
3363
+ });
3364
+ }
3365
+ } else {
3366
+ const content = input.proposal.payload.content;
3367
+ if (typeof content !== "object" || content === null || Array.isArray(content))
3368
+ throw new BrandInputError("proposal payload.content must be an object");
3369
+ ops = [
3370
+ ...upsertSectionOps({
3371
+ bookId: input.book.id,
3372
+ kind: input.proposal.kind,
3373
+ content,
3374
+ status: "approved",
3375
+ audience: input.book.memberIds,
3376
+ updatedBy: input.actorId,
3377
+ approvalReceiptId: input.receiptId,
3378
+ approvalActionDigest: input.actionDigest,
3379
+ now: input.now
3380
+ }),
3381
+ {
3382
+ t: "update",
3383
+ ns: BRAND_NS.book,
3384
+ id: input.book.id,
3385
+ attrs: {
3386
+ activationRevision: input.book.activationRevision + 1,
3387
+ version: input.book.version + 1,
3388
+ updatedAt: input.now
3389
+ }
3390
+ },
3391
+ {
3392
+ t: "update",
3393
+ ns: BRAND_NS.proposal,
3394
+ id: input.proposal.id,
3395
+ attrs: {
3396
+ status: "accepted",
3397
+ resolvedBy: input.actorId,
3398
+ resolvedAt: input.now,
3399
+ ...input.resolutionNote ? { resolutionNote: input.resolutionNote } : {}
3400
+ }
3401
+ }
3402
+ ];
3403
+ if (input.proposal.kind === "typography" && input.priorPalette) {
3404
+ const bookOp = ops.find(
3405
+ (op) => op.t === "update" && op.ns === BRAND_NS.book && op.id === input.book.id
3406
+ );
3407
+ if (bookOp?.t === "update") {
3408
+ if (!input.priorPalette.approvalReceiptId || !input.priorPalette.approvalActionDigest)
3409
+ throw new BrandInputError("active palette is missing approval provenance");
3410
+ bookOp.attrs.tokens = await tokenSnapshot(
3411
+ input.priorPalette.swatches,
3412
+ content,
3413
+ input,
3414
+ input.priorPalette.id,
3415
+ input.priorPalette.approvalReceiptId,
3416
+ input.priorPalette.approvalActionDigest,
3417
+ input.receiptId,
3418
+ input.actionDigest
3419
+ );
3420
+ }
3421
+ }
3422
+ }
3423
+ stampProposal(ops, input);
3424
+ return ops;
3425
+ }
3426
+
3427
+ // src/routes/proposal-resolution-receipts.ts
3428
+ async function proposalResolutionMutationKey(bookId, proposalId, mutationId) {
3429
+ const digest = await brandJsonDigest({
3430
+ version: 3,
3431
+ bookId,
3432
+ proposalId,
3433
+ mutationId
3434
+ });
3435
+ return `brand:proposal-resolution:v3:${digest.slice("sha256:".length)}`;
3436
+ }
3437
+ async function proposalReceiptByMutation(ctx, mutationKey) {
3438
+ const result = await ctx.db.query({
3439
+ [BRAND_NS.approvalReceipt]: {
3440
+ $: { where: { mutationKey } },
3441
+ book: {}
3442
+ }
3443
+ });
3444
+ const row = (result[BRAND_NS.approvalReceipt] ?? [])[0];
3445
+ if (!row || !Array.isArray(row.book) || row.book.length !== 1 || row.book[0]?.id !== row.bookId) return void 0;
3446
+ const { book: _book, ...receipt2 } = row;
3447
+ return receipt2;
3448
+ }
3449
+ async function consumeProposalAuthority(ctx, req, bookId, proposalId, actionDigest, mutationKey) {
3450
+ const authority = await ctx.consumeHumanExact({
3451
+ req,
3452
+ actor: ctx.actor,
3453
+ appId: ctx.appId,
3454
+ capability: "brand.proposal.resolve",
3455
+ projectCapability: "brand.approve",
3456
+ effect: "internal",
3457
+ resource: { bookId, proposalId },
3458
+ actionDigest,
3459
+ consumptionIdempotencyKey: mutationKey
3460
+ });
3461
+ const resourceDigest = await brandJsonDigest({ bookId, proposalId });
3462
+ if (!isBrandHumanAuthorityConsumption(authority) || authority.actorPrincipalId !== ctx.actor.id || authority.appId !== ctx.appId || authority.actionDigest !== actionDigest || authority.resourceDigest !== resourceDigest || authority.consumptionIdempotencyKey !== mutationKey) throw new BrandForbiddenError(
3463
+ "a verified human-exact brand approval is required"
3464
+ );
3465
+ return authority;
3466
+ }
3467
+ function sameProposalResolutionRequest(receipt2, reviewed, resolution, note, paletteId) {
3468
+ return receipt2.resolution === resolution && (receipt2.resolutionNote ?? null) === (note ?? null) && (receipt2.paletteId ?? null) === (paletteId ?? null) && canonicalBrandJson(receipt2.reviewedProposal) === canonicalBrandJson(reviewed);
3469
+ }
3470
+
3471
+ // src/routes/proposal-dependencies.ts
3472
+ async function receipt(ctx, id, actionDigest, expected) {
3473
+ const result = await ctx.db.query({
3474
+ [BRAND_NS.approvalReceipt]: {
3475
+ $: { where: { id } },
3476
+ book: {}
3477
+ }
3478
+ });
3479
+ const row = (result[BRAND_NS.approvalReceipt] ?? [])[0];
3480
+ if (!row || row.actionDigest !== actionDigest || row.resolution !== "accepted" || row.bookId !== expected.bookId || row.reviewedProposal.bookId !== expected.bookId || row.reviewedProposal.kind !== expected.kind || !Array.isArray(row.book) || row.book.length !== 1 || row.book[0]?.id !== expected.bookId || expected.paletteId !== void 0 && row.paletteId !== expected.paletteId || expected.proposalId !== void 0 && row.proposalId !== expected.proposalId || expected.content !== void 0 && canonicalBrandJson(row.reviewedProposal.payload.content) !== canonicalBrandJson(expected.content)) throw new BrandConflictError(`approval receipt ${id} is invalid`);
3481
+ const { book: _book, ...unhydrated } = row;
3482
+ if (!await verifyBrandApprovalReceipt(unhydrated))
3483
+ throw new BrandConflictError(`approval receipt ${id} is invalid`);
3484
+ return unhydrated;
3485
+ }
3486
+ async function activePalette(ctx, book) {
3487
+ if (!book.activePaletteId) return {};
3488
+ const result = await ctx.db.query({
3489
+ [BRAND_NS.palette]: {
3490
+ $: { where: { id: book.activePaletteId } },
3491
+ book: {}
3492
+ }
3493
+ });
3494
+ const palette = (result[BRAND_NS.palette] ?? [])[0];
3495
+ if (!palette || palette.bookId !== book.id || palette.status !== "active" || !palette.proposalId || !palette.approvalReceiptId || !palette.approvalActionDigest || !Array.isArray(palette.book) || palette.book.length !== 1 || palette.book[0]?.id !== book.id) throw new BrandConflictError(
3496
+ "active palette is missing valid approval provenance"
3497
+ );
3498
+ const approval = await receipt(
3499
+ ctx,
3500
+ palette.approvalReceiptId,
3501
+ palette.approvalActionDigest,
3502
+ {
3503
+ bookId: book.id,
3504
+ kind: "palette",
3505
+ paletteId: palette.id,
3506
+ proposalId: palette.proposalId
3507
+ }
3508
+ );
3509
+ let expectedName;
3510
+ let expectedSwatches;
3511
+ let expectedSeed;
3512
+ try {
3513
+ expectedName = capString(
3514
+ approval.reviewedProposal.payload.name,
3515
+ "reviewedProposal.payload.name",
3516
+ 120
3517
+ );
3518
+ expectedSwatches = assertSwatches(
3519
+ approval.reviewedProposal.payload.swatches
3520
+ );
3521
+ expectedSeed = approval.reviewedProposal.payload.seedHex === void 0 ? void 0 : assertHex(
3522
+ approval.reviewedProposal.payload.seedHex,
3523
+ "reviewedProposal.payload.seedHex"
3524
+ );
3525
+ } catch {
3526
+ throw new BrandConflictError("active palette approval payload is invalid");
3527
+ }
3528
+ const expectedSource = approval.reviewedProposal.provenance.sourceAsset ? "extracted" : expectedSeed ? "derived" : "manual";
3529
+ if (palette.proposalId !== approval.proposalId || palette.name !== expectedName || canonicalBrandJson(palette.swatches) !== canonicalBrandJson(expectedSwatches) || (palette.seedHex ?? null) !== (expectedSeed ?? null) || palette.source !== expectedSource || palette.rationale !== approval.reviewedProposal.rationale || canonicalBrandJson(palette.audience) !== canonicalBrandJson(approval.reviewedProposal.audience) || palette.createdAt !== approval.createdAt || palette.updatedAt !== approval.createdAt) throw new BrandConflictError(
3530
+ "active palette does not match its approved proposal"
3531
+ );
3532
+ return { palette, receipt: approval };
3533
+ }
3534
+ async function approvedTypography(ctx, book) {
3535
+ const key = `${book.id}:typography`;
3536
+ const result = await ctx.db.query({
3537
+ [BRAND_NS.section]: {
3538
+ $: { where: { key } },
3539
+ book: {}
3540
+ }
3541
+ });
3542
+ const section = (result[BRAND_NS.section] ?? [])[0];
3543
+ if (!section || section.status !== "approved") return {};
3544
+ if (section.bookId !== book.id || section.kind !== "typography" || !section.approvalReceiptId || !section.approvalActionDigest || !Array.isArray(section.book) || section.book.length !== 1 || section.book[0]?.id !== book.id) throw new BrandConflictError(
3545
+ "approved typography is missing valid approval provenance"
3546
+ );
3547
+ assertSectionContent("typography", section.content);
3548
+ return {
3549
+ section,
3550
+ receipt: await receipt(
3551
+ ctx,
3552
+ section.approvalReceiptId,
3553
+ section.approvalActionDigest,
3554
+ { bookId: book.id, kind: "typography", content: section.content }
3555
+ )
3556
+ };
3557
+ }
3558
+ var receiptGuard = (value) => ({
3559
+ ns: BRAND_NS.approvalReceipt,
3560
+ id: value.id,
3561
+ exists: true,
3562
+ equals: {
3563
+ actionDigest: value.actionDigest,
3564
+ receiptDigest: value.receiptDigest,
3565
+ resolution: "accepted"
3566
+ }
3567
+ });
3568
+
3569
+ // src/routes/proposal-state.ts
3570
+ async function proposalDecisionState(ctx, req, book, proposal, resolution, receiptId, paletteId) {
3571
+ const needsPalette = resolution === "accepted" && (proposal.kind === "palette" || proposal.kind === "typography");
3572
+ const paletteDep = needsPalette ? await activePalette(ctx, book) : {};
3573
+ const typeDep = resolution === "accepted" && proposal.kind === "palette" ? await approvedTypography(ctx, book) : {};
3574
+ const dependencyGuards = [];
3575
+ const source = proposal.provenance.sourceAsset;
3576
+ let sourceAsset;
3577
+ if (source && resolution === "accepted") {
3578
+ const result = await ctx.db.query({
3579
+ [BRAND_NS.asset]: {
3580
+ $: { where: { id: source.assetId, bookId: book.id } },
3581
+ book: {}
3582
+ }
3583
+ });
3584
+ sourceAsset = (result[BRAND_NS.asset] ?? [])[0];
3585
+ if (!sourceAsset || sourceAsset.id !== source.assetId || sourceAsset.bookId !== book.id || sourceAsset.status !== "live" || sourceAsset.deletedAt !== void 0 || sourceAsset.contentDigest !== source.contentDigest || sourceAsset.size !== source.objectSize || sourceAsset.contentType !== source.contentType || await brandJsonDigest({ appId: ctx.appId, path: sourceAsset.path }) !== source.pathDigest || sourceAsset.analysisRevision !== source.analysisRevision || (sourceAsset.analysisDigest ?? null) !== source.analysisDigest || !Array.isArray(sourceAsset.book) || sourceAsset.book.length !== 1 || sourceAsset.book[0]?.id !== book.id) throw new BrandConflictError(
3586
+ "source asset changed since the proposal was reviewed"
3587
+ );
3588
+ if (!await ctx.verifySourceAssetSnapshot({
3589
+ req,
3590
+ appId: ctx.appId,
3591
+ bookId: book.id,
3592
+ assetId: source.assetId,
3593
+ path: sourceAsset.path,
3594
+ snapshot: source
3595
+ })) throw new BrandConflictError(
3596
+ "source asset bytes changed since the proposal was reviewed"
3597
+ );
3598
+ dependencyGuards.push({
3599
+ ns: BRAND_NS.asset,
3600
+ id: sourceAsset.id,
3601
+ exists: true,
3602
+ equals: {
3603
+ bookId: book.id,
3604
+ status: "live",
3605
+ path: sourceAsset.path,
3606
+ storageObjectId: sourceAsset.storageObjectId,
3607
+ contentDigest: source.contentDigest,
3608
+ contentType: source.contentType,
3609
+ size: source.objectSize,
3610
+ analysisRevision: source.analysisRevision,
3611
+ ...source.analysisDigest ? { analysisDigest: source.analysisDigest } : {}
3612
+ }
3613
+ });
3614
+ }
3615
+ if (paletteDep.palette && paletteDep.receipt) {
3616
+ dependencyGuards.push({
3617
+ ns: BRAND_NS.palette,
3618
+ id: paletteDep.palette.id,
3619
+ exists: true,
3620
+ equals: {
3621
+ bookId: book.id,
3622
+ status: "active",
3623
+ name: paletteDep.palette.name,
3624
+ swatches: paletteDep.palette.swatches,
3625
+ seedHex: paletteDep.palette.seedHex ?? null,
3626
+ source: paletteDep.palette.source,
3627
+ rationale: paletteDep.palette.rationale,
3628
+ proposalId: paletteDep.palette.proposalId,
3629
+ approvalReceiptId: paletteDep.palette.approvalReceiptId,
3630
+ approvalActionDigest: paletteDep.palette.approvalActionDigest,
3631
+ audience: paletteDep.palette.audience,
3632
+ createdAt: paletteDep.palette.createdAt,
3633
+ updatedAt: paletteDep.palette.updatedAt
3634
+ }
3635
+ }, receiptGuard(paletteDep.receipt));
3636
+ }
3637
+ if (typeDep.section && typeDep.receipt) {
3638
+ dependencyGuards.push({
3639
+ ns: BRAND_NS.section,
3640
+ id: typeDep.section.id,
3641
+ exists: true,
3642
+ equals: {
3643
+ key: typeDep.section.key,
3644
+ bookId: book.id,
3645
+ kind: "typography",
3646
+ status: "approved",
3647
+ content: typeDep.section.content,
3648
+ approvalReceiptId: typeDep.section.approvalReceiptId,
3649
+ approvalActionDigest: typeDep.section.approvalActionDigest,
3650
+ updatedAt: typeDep.section.updatedAt
3651
+ }
3652
+ }, receiptGuard(typeDep.receipt));
3653
+ }
3654
+ let approvedContent = null;
3655
+ let paletteResult = null;
3656
+ if (resolution === "accepted" && proposal.kind === "palette") {
3657
+ if (!paletteId) throw new BrandNotFoundError("resulting palette id");
3658
+ paletteResult = {
3659
+ id: paletteId,
3660
+ name: capString(proposal.payload.name, "payload.name", 120),
3661
+ swatches: assertSwatches(proposal.payload.swatches),
3662
+ seedHex: proposal.payload.seedHex ?? null,
3663
+ receiptId,
3664
+ archivePaletteId: paletteDep.palette?.id ?? null
3665
+ };
3666
+ } else if (resolution === "accepted") {
3667
+ approvedContent = assertSectionContent(
3668
+ proposal.kind,
3669
+ proposal.payload.content
3670
+ );
3671
+ }
3672
+ const tokenSources = resolution === "accepted" && (proposal.kind === "palette" || proposal.kind === "typography") ? {
3673
+ paletteReceiptId: proposal.kind === "palette" ? receiptId : paletteDep.palette?.approvalReceiptId ?? null,
3674
+ paletteActionDigest: proposal.kind === "palette" ? "this-action" : paletteDep.palette?.approvalActionDigest ?? null,
3675
+ typographyReceiptId: proposal.kind === "typography" ? receiptId : typeDep.section?.approvalReceiptId ?? null,
3676
+ typographyActionDigest: proposal.kind === "typography" ? "this-action" : typeDep.section?.approvalActionDigest ?? null
3677
+ } : null;
3678
+ const effectDescriptor = {
3679
+ proposalId: proposal.id,
3680
+ resolution,
3681
+ nextBookVersion: resolution === "accepted" ? book.version + 1 : null,
3682
+ nextActivationRevision: resolution === "accepted" ? book.activationRevision + 1 : null,
3683
+ resultingActivePaletteId: proposal.kind === "palette" && resolution === "accepted" ? paletteId ?? null : book.activePaletteId ?? null,
3684
+ palette: paletteResult,
3685
+ approvedSection: approvedContent ? { kind: proposal.kind, content: approvedContent, receiptId } : null,
3686
+ tokenSources
3687
+ };
3688
+ const binding = {
3689
+ version: 1,
3690
+ bookVersion: book.version,
3691
+ activationRevision: book.activationRevision,
3692
+ activePaletteId: book.activePaletteId ?? null,
3693
+ memberIdsDigest: await brandJsonDigest(book.memberIds),
3694
+ activePaletteDigest: paletteDep.palette ? await brandJsonDigest({
3695
+ id: paletteDep.palette.id,
3696
+ swatches: paletteDep.palette.swatches,
3697
+ approvalReceiptId: paletteDep.palette.approvalReceiptId,
3698
+ approvalActionDigest: paletteDep.palette.approvalActionDigest
3699
+ }) : null,
3700
+ typographyDigest: typeDep.section ? await brandJsonDigest({
3701
+ key: typeDep.section.key,
3702
+ content: typeDep.section.content,
3703
+ approvalReceiptId: typeDep.section.approvalReceiptId,
3704
+ approvalActionDigest: typeDep.section.approvalActionDigest
3705
+ }) : null,
3706
+ sourceAssetDigest: source ? await brandJsonDigest(source) : null,
3707
+ effectDigest: await brandJsonDigest(effectDescriptor)
3708
+ };
3709
+ return {
3710
+ ...paletteDep.palette ? { priorPalette: paletteDep.palette } : {},
3711
+ ...typeDep.section ? { approvedTypography: typeDep.section } : {},
3712
+ binding,
3713
+ effectDescriptor,
3714
+ dependencyGuards
3715
+ };
3716
+ }
3717
+
3718
+ // src/routes/proposals.ts
3719
+ var record4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3720
+ var sameStrings2 = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);
3721
+ var guardFailure2 = (error) => record4(error) && error.code === "transact_guard_failed";
3722
+ async function handleProposalResolution(ctx, req, bookId, proposalId) {
3723
+ if (req.method !== "POST") return methodNotAllowed();
3724
+ const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
3725
+ if (ctx.actor.kind !== "human")
3726
+ throw new BrandForbiddenError("only a directly authenticated human can resolve a proposal");
3727
+ const body = await readJson(req);
3728
+ const {
3729
+ mutationId,
3730
+ resolution,
3731
+ reviewed,
3732
+ resolutionNote
3733
+ } = parseProposalResolutionRequest(body, bookId, proposalId);
3734
+ const mutationKey = await proposalResolutionMutationKey(
3735
+ bookId,
3736
+ proposalId,
3737
+ mutationId
3738
+ );
3739
+ const stable = (await brandJsonDigest({ mutationKey })).slice(7);
3740
+ const receiptId = `brand-receipt-${stable.slice(0, 32)}`;
3741
+ const paletteId = resolution === "accepted" && reviewed.kind === "palette" ? `brand-palette-${stable.slice(32)}` : void 0;
3742
+ const existing = await proposalReceiptByMutation(ctx, mutationKey);
3743
+ if (existing) {
3744
+ if (!await verifyBrandApprovalReceipt(existing) || !sameProposalResolutionRequest(
3745
+ existing,
3746
+ reviewed,
3747
+ resolution,
3748
+ resolutionNote,
3749
+ paletteId
3750
+ )) throw new BrandConflictError("mutationId was used for another decision");
3751
+ const replayed = await consumeProposalAuthority(
3752
+ ctx,
3753
+ req,
3754
+ bookId,
3755
+ proposalId,
3756
+ existing.actionDigest,
3757
+ mutationKey
3758
+ );
3759
+ if (canonicalBrandJson(replayed) !== canonicalBrandJson(existing.authorityConsumption)) throw new BrandConflictError("central approval receipt changed");
3760
+ return json({ receipt: existing, duplicate: true });
3761
+ }
3762
+ const result = await ctx.db.query({
3763
+ [BRAND_NS.proposal]: {
3764
+ $: { where: { id: proposalId, bookId } },
3765
+ book: {}
3766
+ }
3767
+ });
3768
+ const proposal = (result[BRAND_NS.proposal] ?? [])[0];
3769
+ if (!proposal || proposal.bookId !== book.id || !Array.isArray(proposal.book) || proposal.book.length !== 1 || proposal.book[0]?.id !== book.id) throw new BrandNotFoundError(`proposal ${proposalId}`);
3770
+ const current = proposalReviewSnapshot(proposal);
3771
+ const { reviewDigest, ...unsignedReview } = current;
3772
+ if (await brandJsonDigest(unsignedReview) !== reviewDigest || canonicalBrandJson(current) !== canonicalBrandJson(reviewed)) throw new BrandConflictError("proposal changed since review");
3773
+ if (resolution === "accepted" && !sameStrings2(current.audience, book.memberIds))
3774
+ throw new BrandConflictError("book membership changed; review the proposal again");
3775
+ const state = await proposalDecisionState(
3776
+ ctx,
3777
+ req,
3778
+ book,
3779
+ proposal,
3780
+ resolution,
3781
+ receiptId,
3782
+ paletteId
3783
+ );
3784
+ const actionDigest = await brandJsonDigest({
3785
+ version: 1,
3786
+ reviewedProposal: current,
3787
+ resolution,
3788
+ resolutionNote: resolutionNote ?? null,
3789
+ paletteId: paletteId ?? null,
3790
+ decisionBinding: state.binding
3791
+ });
3792
+ const authority = await consumeProposalAuthority(
3793
+ ctx,
3794
+ req,
3795
+ bookId,
3796
+ proposalId,
3797
+ actionDigest,
3798
+ mutationKey
3799
+ );
3800
+ const receiptBase = {
3801
+ version: 1,
3802
+ id: receiptId,
3803
+ mutationKey,
3804
+ bookId,
3805
+ proposalId,
3806
+ resolution,
3807
+ ...paletteId ? { paletteId } : {},
3808
+ ...resolutionNote ? { resolutionNote } : {},
3809
+ reviewedProposal: current,
3810
+ actionDigest,
3811
+ decisionBinding: state.binding,
3812
+ approvedBy: ctx.actor.id,
3813
+ approvedByKind: "human",
3814
+ appliedBy: ctx.actor.id,
3815
+ appliedByKind: "human",
3816
+ authorityRef: brandAuthorityRef(authority),
3817
+ authorityCapability: "brand.approve",
3818
+ authorityConsumption: authority,
3819
+ createdAt: authority.consumedAt
3820
+ };
3821
+ const receipt2 = {
3822
+ ...receiptBase,
3823
+ receiptDigest: await brandJsonDigest(receiptBase)
3824
+ };
3825
+ const ops = await proposalDecisionOps({
3826
+ proposal: { ...proposal, audience: [...book.memberIds] },
3827
+ book,
3828
+ resolution,
3829
+ receiptId,
3830
+ actionDigest,
3831
+ ...paletteId ? { paletteId } : {},
3832
+ ...state.priorPalette ? { priorPalette: state.priorPalette } : {},
3833
+ ...state.approvedTypography ? { approvedTypography: state.approvedTypography } : {},
3834
+ actorId: ctx.actor.id,
3835
+ now: authority.consumedAt,
3836
+ resolutionNote
3837
+ });
3838
+ ops.push({
3839
+ t: "update",
3840
+ ns: BRAND_NS.approvalReceipt,
3841
+ id: receipt2.id,
3842
+ attrs: receipt2
3843
+ }, {
3844
+ t: "link",
3845
+ ns: BRAND_NS.approvalReceipt,
3846
+ id: receipt2.id,
3847
+ label: "book",
3848
+ target: bookId
3849
+ });
3850
+ const guards = [
3851
+ {
3852
+ ns: BRAND_NS.proposal,
3853
+ id: proposalId,
3854
+ exists: true,
3855
+ equals: proposalGuardEquals(current)
3856
+ },
3857
+ { ns: BRAND_NS.approvalReceipt, id: receiptId, exists: false },
3858
+ {
3859
+ ns: BRAND_NS.book,
3860
+ id: bookId,
3861
+ exists: true,
3862
+ equals: {
3863
+ version: book.version,
3864
+ activationRevision: book.activationRevision,
3865
+ memberIds: book.memberIds
3866
+ }
3867
+ },
3868
+ ...state.dependencyGuards
3869
+ ];
3870
+ if (paletteId) {
3871
+ guards.push({ ns: BRAND_NS.palette, id: paletteId, exists: false });
3872
+ }
3873
+ try {
3874
+ const tx = await ctx.db.transact(ops, {
3875
+ mutationId: mutationKey,
3876
+ guards,
3877
+ asUser: ctx.actor.id,
3878
+ ...ctx.actor.email ? { asEmail: ctx.actor.email } : {},
3879
+ asPrincipalKind: "human"
3880
+ });
3881
+ if (tx.duplicate) {
3882
+ const replay = await proposalReceiptByMutation(ctx, mutationKey);
3883
+ if (!replay || !await verifyBrandApprovalReceipt(replay) || !sameProposalResolutionRequest(
3884
+ replay,
3885
+ reviewed,
3886
+ resolution,
3887
+ resolutionNote,
3888
+ paletteId
3889
+ ) || canonicalBrandJson(replay.authorityConsumption) !== canonicalBrandJson(authority))
3890
+ throw new BrandConflictError("proposal decision replay is invalid");
3891
+ return json({ receipt: replay, duplicate: true });
3892
+ }
3893
+ return json({ receipt: receipt2, duplicate: false });
3894
+ } catch (error) {
3895
+ if (guardFailure2(error)) throw new BrandReviewStateChangedError();
3896
+ throw error;
3897
+ }
3898
+ }
3899
+
2421
3900
  // src/routes/tokens.ts
2422
- var isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2423
- async function resolveTokenMaps(db, book) {
3901
+ var record5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3902
+ var missing = (bookId) => {
3903
+ throw new BrandNotFoundError(`compiled tokens for brand book ${bookId}`);
3904
+ };
3905
+ async function acceptedReceipt(db, bookId, receiptId, actionDigest) {
3906
+ const result = await db.query({
3907
+ [BRAND_NS.approvalReceipt]: {
3908
+ $: { where: { id: receiptId, bookId } },
3909
+ book: {}
3910
+ }
3911
+ });
3912
+ const receipt2 = (result[BRAND_NS.approvalReceipt] ?? [])[0];
3913
+ if (!receipt2 || receipt2.resolution !== "accepted" || receipt2.actionDigest !== actionDigest || !Array.isArray(receipt2.book) || receipt2.book.length !== 1 || receipt2.book[0]?.id !== bookId) return missing(bookId);
3914
+ const { book: _book, ...unhydrated } = receipt2;
3915
+ if (!await verifyBrandApprovalReceipt(unhydrated)) return missing(bookId);
3916
+ return unhydrated;
3917
+ }
3918
+ async function approvedCache(db, book) {
2424
3919
  const cache = book.tokens;
2425
- if (cache && isRecord3(cache.light) && isRecord3(cache.dark))
2426
- return { light: cache.light, dark: cache.dark };
2427
- if (!book.activePaletteId) throw new BrandNotFoundError(`compiled tokens for brand book ${book.id}`);
2428
- const pres = await db.query({
2429
- [BRAND_NS.palette]: { $: { where: { id: book.activePaletteId } } }
3920
+ if (!cache || !record5(cache.light) || !record5(cache.dark) || !book.activePaletteId || cache.paletteId !== book.activePaletteId || !cache.paletteReceiptId || !cache.paletteActionDigest || !cache.sourceDigest) return missing(book.id);
3921
+ const paletteResult = await db.query({
3922
+ [BRAND_NS.palette]: {
3923
+ $: { where: { id: cache.paletteId, bookId: book.id } },
3924
+ book: {}
3925
+ }
3926
+ });
3927
+ const palette = (paletteResult[BRAND_NS.palette] ?? [])[0];
3928
+ if (!palette || palette.status !== "active" || palette.approvalReceiptId !== cache.paletteReceiptId || palette.approvalActionDigest !== cache.paletteActionDigest || !Array.isArray(palette.book) || palette.book.length !== 1 || palette.book[0]?.id !== book.id) return missing(book.id);
3929
+ await acceptedReceipt(
3930
+ db,
3931
+ book.id,
3932
+ cache.paletteReceiptId,
3933
+ cache.paletteActionDigest
3934
+ );
3935
+ const hasTypeId = cache.typographyReceiptId !== void 0;
3936
+ const hasTypeDigest = cache.typographyActionDigest !== void 0;
3937
+ if (hasTypeId !== hasTypeDigest) return missing(book.id);
3938
+ const sectionResult = await db.query({
3939
+ [BRAND_NS.section]: {
3940
+ $: { where: { key: `${book.id}:typography` } },
3941
+ book: {}
3942
+ }
2430
3943
  });
2431
- const palette = (pres[BRAND_NS.palette] ?? [])[0];
2432
- if (!palette) throw new BrandNotFoundError(`compiled tokens for brand book ${book.id}`);
2433
- const sres = await db.query({
2434
- [BRAND_NS.section]: { $: { where: { key: `${book.id}:typography` } } }
3944
+ const typography = (sectionResult[BRAND_NS.section] ?? [])[0];
3945
+ if (hasTypeId) {
3946
+ if (!typography || typography.bookId !== book.id || typography.kind !== "typography" || typography.status !== "approved" || typography.approvalReceiptId !== cache.typographyReceiptId || typography.approvalActionDigest !== cache.typographyActionDigest || !Array.isArray(typography.book) || typography.book.length !== 1 || typography.book[0]?.id !== book.id) return missing(book.id);
3947
+ await acceptedReceipt(
3948
+ db,
3949
+ book.id,
3950
+ cache.typographyReceiptId,
3951
+ cache.typographyActionDigest
3952
+ );
3953
+ } else if (typography?.status === "approved" && (typography.approvalReceiptId || typography.approvalActionDigest)) {
3954
+ return missing(book.id);
3955
+ }
3956
+ const sourceDigest = await brandJsonDigest({
3957
+ paletteId: cache.paletteId,
3958
+ paletteReceiptId: cache.paletteReceiptId,
3959
+ paletteActionDigest: cache.paletteActionDigest,
3960
+ typographyReceiptId: cache.typographyReceiptId ?? null,
3961
+ typographyActionDigest: cache.typographyActionDigest ?? null
2435
3962
  });
2436
- const typography = (sres[BRAND_NS.section] ?? [])[0]?.content;
2437
- const compiled = compileBrandTokens({ swatches: palette.swatches, typography });
2438
- return { light: compiled.light, dark: compiled.dark };
3963
+ if (sourceDigest !== cache.sourceDigest) return missing(book.id);
3964
+ return cache;
2439
3965
  }
2440
3966
  async function handleTokens(db, req, bookId, which, actor) {
2441
3967
  if (req.method !== "GET") return methodNotAllowed();
2442
3968
  const book = actor === null ? await loadBook(db, bookId) : await loadMemberBook(db, bookId, actor.id);
2443
- const maps = await resolveTokenMaps(db, book);
2444
- if (which === "tokens.json") return json({ light: maps.light, dark: maps.dark });
2445
- const etag = `"brand-tokens-${book.updatedAt}"`;
2446
- const inm = req.headers.get("if-none-match");
2447
- if (inm && inm.split(",").map((v) => v.trim()).includes(etag))
2448
- return new Response(null, { status: 304, headers: { etag } });
2449
- return new Response(renderTokensCss(maps.light, { dark: maps.dark }), {
3969
+ const cache = await approvedCache(db, book);
3970
+ const light = cache.light;
3971
+ const dark = cache.dark;
3972
+ if (which === "tokens.json") return json({ light, dark });
3973
+ const etag = `"brand-tokens-${book.activationRevision}-${cache.sourceDigest.slice(7)}"`;
3974
+ if (req.headers.get("if-none-match")?.split(",").map((value) => value.trim()).includes(etag)) return new Response(null, { status: 304, headers: { etag } });
3975
+ return new Response(renderTokensCss(light, { dark }), {
2450
3976
  status: 200,
2451
- headers: { "content-type": "text/css; charset=utf-8", etag }
3977
+ headers: {
3978
+ "content-type": "text/css; charset=utf-8",
3979
+ "cache-control": "public, max-age=0, must-revalidate",
3980
+ etag
3981
+ }
3982
+ });
3983
+ }
3984
+
3985
+ // src/routes/discussion-references.ts
3986
+ var capLimit = (raw) => {
3987
+ const parsed = Number(raw ?? 20);
3988
+ return Number.isInteger(parsed) ? Math.max(1, Math.min(parsed, 20)) : 20;
3989
+ };
3990
+ var targetFromQuery = (url) => {
3991
+ const kind = url.searchParams.get("kind");
3992
+ const id = url.searchParams.get("id");
3993
+ if (!kind && !id) return null;
3994
+ if (!kind || !id) return null;
3995
+ const value = new URL("https://brand.invalid");
3996
+ value.searchParams.set("odla-ref", `${kind}/${id}`);
3997
+ return parseBrandDiscussionReference(value);
3998
+ };
3999
+ var childNamespace = (kind) => ({
4000
+ "brand:asset": BRAND_NS.asset,
4001
+ "brand:palette": BRAND_NS.palette,
4002
+ "brand:proposal": BRAND_NS.proposal,
4003
+ "brand:receipt": BRAND_NS.approvalReceipt
4004
+ })[kind] ?? null;
4005
+ var childTarget = (kind, row) => ({
4006
+ kind,
4007
+ bookId: row.bookId,
4008
+ resourceId: row.id
4009
+ });
4010
+ var projection = (req, ctx, book, target, row) => {
4011
+ let label = book.name;
4012
+ let hint = `${book.status} brand book`;
4013
+ let summary = `${book.name} brand book`;
4014
+ let status = book.status;
4015
+ let destination = "Brand Studio \xB7 Brand book";
4016
+ if (target.kind === "brand:asset") {
4017
+ const asset = row;
4018
+ label = asset.title?.trim() || `${asset.kind} asset`;
4019
+ hint = `${book.name} \xB7 ${asset.status}`;
4020
+ summary = `${asset.kind} source asset in ${book.name}`;
4021
+ status = asset.status;
4022
+ destination = "Brand Studio \xB7 Conversation";
4023
+ } else if (target.kind === "brand:palette") {
4024
+ const palette = row;
4025
+ label = palette.name;
4026
+ hint = `${book.name} \xB7 ${palette.status} palette`;
4027
+ summary = `${palette.swatches.length}-color palette in ${book.name}`;
4028
+ status = palette.status;
4029
+ destination = "Brand Studio \xB7 Review changes";
4030
+ } else if (target.kind === "brand:proposal") {
4031
+ const proposal = row;
4032
+ label = `${proposal.kind} proposal`;
4033
+ hint = `${book.name} \xB7 ${proposal.status}`;
4034
+ summary = proposal.rationale;
4035
+ status = proposal.status;
4036
+ destination = "Brand Studio \xB7 Review changes";
4037
+ } else if (target.kind === "brand:receipt") {
4038
+ const receipt2 = row;
4039
+ label = `${receipt2.resolution} ${receipt2.reviewedProposal.kind} change`;
4040
+ hint = `${book.name} \xB7 approval receipt`;
4041
+ summary = `${receipt2.resolution} ${receipt2.reviewedProposal.kind} decision`;
4042
+ status = receipt2.resolution;
4043
+ destination = "Brand Studio \xB7 Review changes";
4044
+ }
4045
+ return {
4046
+ kind: target.kind,
4047
+ id: brandDiscussionReferenceId(target),
4048
+ label,
4049
+ hint,
4050
+ summary,
4051
+ status,
4052
+ destination,
4053
+ href: brandDiscussionReferenceHref(
4054
+ new URL(req.url),
4055
+ target,
4056
+ ctx.discussionBasePath
4057
+ )
4058
+ };
4059
+ };
4060
+ async function exact(ctx, req, target) {
4061
+ const book = await loadMemberBook(ctx.db, target.bookId, ctx.actor.id).catch(() => null);
4062
+ if (!book) return [];
4063
+ if (target.kind === "brand:book") {
4064
+ return [projection(req, ctx, book, target, book)];
4065
+ }
4066
+ const namespace = childNamespace(target.kind);
4067
+ if (!namespace || !target.resourceId) return [];
4068
+ const result = await ctx.db.query({
4069
+ [namespace]: {
4070
+ $: { where: { id: target.resourceId }, limit: 1 }
4071
+ }
4072
+ });
4073
+ const row = (result[namespace] ?? [])[0];
4074
+ if (!row || row.bookId !== book.id || target.kind === "brand:asset" && row.status !== "live") return [];
4075
+ return [projection(req, ctx, book, target, row)];
4076
+ }
4077
+ var matches = (item, needle) => !needle || `${item.label}
4078
+ ${item.hint}
4079
+ ${item.id}`.toLowerCase().includes(needle);
4080
+ async function search(ctx, req, url) {
4081
+ const result = await ctx.db.query({
4082
+ [BRAND_NS.book]: { $: { order: { updatedAt: "desc" }, limit: 100 } },
4083
+ [BRAND_NS.asset]: { $: { order: { createdAt: "desc" }, limit: 100 } },
4084
+ [BRAND_NS.palette]: { $: { order: { updatedAt: "desc" }, limit: 100 } },
4085
+ [BRAND_NS.proposal]: { $: { order: { createdAt: "desc" }, limit: 100 } },
4086
+ [BRAND_NS.approvalReceipt]: {
4087
+ $: { order: { createdAt: "desc" }, limit: 100 }
4088
+ }
4089
+ });
4090
+ const books = (result[BRAND_NS.book] ?? []).filter((book) => isMember(book, ctx.actor.id));
4091
+ const bookById = new Map(books.map((book) => [book.id, book]));
4092
+ const items = books.map(
4093
+ (book) => projection(req, ctx, book, { kind: "brand:book", bookId: book.id }, book)
4094
+ );
4095
+ const append = (kind, rows) => {
4096
+ for (const row of rows) {
4097
+ const book = bookById.get(row.bookId);
4098
+ if (!book || kind === "brand:asset" && row.status !== "live") continue;
4099
+ items.push(projection(req, ctx, book, childTarget(kind, row), row));
4100
+ }
4101
+ };
4102
+ append("brand:asset", result[BRAND_NS.asset] ?? []);
4103
+ append("brand:palette", result[BRAND_NS.palette] ?? []);
4104
+ append("brand:proposal", result[BRAND_NS.proposal] ?? []);
4105
+ append("brand:receipt", result[BRAND_NS.approvalReceipt] ?? []);
4106
+ const needle = (url.searchParams.get("q") ?? "").trim().toLowerCase().slice(0, 120);
4107
+ return items.filter((item) => matches(item, needle)).slice(0, capLimit(url.searchParams.get("limit")));
4108
+ }
4109
+ async function handleBrandDiscussionReferences(ctx, req, url) {
4110
+ if (req.method !== "GET") return methodNotAllowed();
4111
+ const kind = url.searchParams.get("kind");
4112
+ const id = url.searchParams.get("id");
4113
+ if (kind && !id || !kind && id) return json({ error: "kind and id must be paired" }, 400);
4114
+ const target = targetFromQuery(url);
4115
+ if ((kind || id) && !target) return json({ items: [] });
4116
+ return json({
4117
+ items: target ? await exact(ctx, req, target) : await search(ctx, req, url)
2452
4118
  });
2453
4119
  }
2454
4120
 
2455
4121
  // src/routes/index.ts
2456
4122
  var tokensFile = (seg) => seg.length === 3 && seg[0] === "books" && (seg[2] === "tokens.css" || seg[2] === "tokens.json") ? seg[2] : null;
2457
- async function route(ctx, req, seg) {
2458
- const [head, id, sub, subId] = seg;
4123
+ async function route(ctx, req, url, seg) {
4124
+ const [head, id, sub, subId, action] = seg;
4125
+ if (head === "discussion-references" && seg.length === 1)
4126
+ return handleBrandDiscussionReferences(ctx, req, url);
2459
4127
  if (head !== "books") return null;
2460
4128
  if (seg.length === 1) return handleBooksRoot(ctx, req);
2461
4129
  if (seg.length === 2) return handleBookItem(ctx, req, id);
4130
+ if (seg.length === 3 && sub === "members") return handleBookMembers(ctx, req, id);
2462
4131
  if (sub === "assets") {
2463
4132
  if (seg.length === 3) return handleAssetsRoot(ctx, req, id);
2464
4133
  if (seg.length === 4) return handleAssetItem(ctx, req, id, subId);
4134
+ if (seg.length === 5 && action === "content")
4135
+ return handleAssetContent(ctx, req, id, subId);
2465
4136
  return null;
2466
4137
  }
4138
+ if (sub === "proposals" && seg.length === 5 && action === "resolve")
4139
+ return handleProposalResolution(ctx, req, id, subId);
2467
4140
  const file = tokensFile(seg);
2468
4141
  if (file) return handleTokens(ctx.db, req, id, file, ctx.actor);
2469
4142
  return null;
@@ -2473,9 +4146,14 @@ function createBrandRoutes(options) {
2473
4146
  const publicTokens = options.publicTokens === true;
2474
4147
  const base = {
2475
4148
  db: options.db,
4149
+ appId: options.appId,
4150
+ authorizeCapability: options.authorizeCapability,
4151
+ consumeHumanExact: options.consumeHumanExact,
4152
+ verifySourceAssetSnapshot: options.verifySourceAssetSnapshot,
2476
4153
  now: options.now ?? Date.now,
2477
4154
  newId: options.newId ?? (() => crypto.randomUUID()),
2478
- maxUploadBytes: options.maxUploadBytes ?? 8 * 1024 * 1024
4155
+ maxUploadBytes: options.maxUploadBytes ?? 8 * 1024 * 1024,
4156
+ discussionBasePath: options.discussionBasePath ?? "/"
2479
4157
  };
2480
4158
  return async (req) => {
2481
4159
  const url = new URL(req.url);
@@ -2484,15 +4162,44 @@ function createBrandRoutes(options) {
2484
4162
  try {
2485
4163
  const file = tokensFile(seg);
2486
4164
  if (publicTokens && file) return await handleTokens(base.db, req, seg[1], file, null);
2487
- const actor = await options.authorize(req);
4165
+ const actor = await (seg[0] === "discussion-references" ? options.authorizeDiscussionReferences ?? options.authorize : options.authorize)(req);
2488
4166
  if (!actor) return json({ error: "unauthorized" }, 401);
2489
- return await route({ ...base, actor }, req, seg) ?? json({ error: "not found" }, 404);
4167
+ return await route({ ...base, actor }, req, url, seg) ?? json({ error: "not found" }, 404);
2490
4168
  } catch (error) {
2491
4169
  return errorResponse(error);
2492
4170
  }
2493
4171
  };
2494
4172
  }
2495
4173
 
4174
+ // src/dispatch-assets.ts
4175
+ var MAX_MESSAGE_ASSETS = 12;
4176
+ function candidateAssetIds(row) {
4177
+ const standard = Array.isArray(row.attachments) ? row.attachments.flatMap((value) => {
4178
+ if (!value || typeof value !== "object" || Array.isArray(value))
4179
+ return [];
4180
+ const id = value.id;
4181
+ return typeof id === "string" && id.length > 0 && id.length <= 200 ? [id] : [];
4182
+ }) : [];
4183
+ const legacy = Array.isArray(row.assetIds) ? row.assetIds.filter((value) => typeof value === "string" && value.length > 0 && value.length <= 200) : [];
4184
+ return [.../* @__PURE__ */ new Set([...standard, ...legacy])].slice(0, MAX_MESSAGE_ASSETS);
4185
+ }
4186
+ async function attachedBrandAssetIds(db, book, row) {
4187
+ const candidates = candidateAssetIds(row);
4188
+ const assets = await Promise.all(candidates.map(async (id) => {
4189
+ const result = await db.query({
4190
+ [BRAND_NS.asset]: {
4191
+ $: { where: { id, bookId: book.id, status: "live" }, limit: 2 },
4192
+ book: { $: { limit: 2 } }
4193
+ }
4194
+ });
4195
+ const rows = result[BRAND_NS.asset] ?? [];
4196
+ if (rows.length !== 1) return null;
4197
+ const asset = rows[0];
4198
+ return asset.id === id && asset.bookId === book.id && asset.status === "live" && asset.deletedAt === void 0 && Array.isArray(asset.book) && asset.book.length === 1 && asset.book[0]?.id === book.id ? id : null;
4199
+ }));
4200
+ return assets.filter((id) => id !== null);
4201
+ }
4202
+
2496
4203
  // src/triggers.ts
2497
4204
  var CHAT_MESSAGE_NS = "chat_message";
2498
4205
  var celString = (value) => value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
@@ -2514,73 +4221,133 @@ function brandBotTrigger(opts) {
2514
4221
  // src/dispatch.ts
2515
4222
  function parseBrandDispatch(raw) {
2516
4223
  if (!raw || typeof raw !== "object") return null;
2517
- const b = raw;
2518
- const t = b.trigger;
2519
- const e = b.event;
2520
- if (typeof b.appId !== "string" || !b.appId) return null;
2521
- if (!t || typeof t.runAs?.agentId !== "string" || typeof t.runAs?.persona !== "string") return null;
2522
- if (!e || typeof e.id !== "string" || !e.row || typeof e.row !== "object") return null;
4224
+ const body = raw;
4225
+ const trigger = body.trigger;
4226
+ const event = body.event;
4227
+ if (body.v !== 2 || typeof body.appId !== "string" || !body.appId || typeof body.appIncarnation !== "string" || !/^[0-9a-f]{32}$/.test(body.appIncarnation) || typeof body.jobId !== "string" || !body.jobId || !trigger || typeof trigger.runAs?.agentId !== "string" || !trigger.runAs.agentId || typeof trigger.runAs.persona !== "string" || !event || typeof event.id !== "string" || !event.row || typeof event.row !== "object" || Array.isArray(event.row)) return null;
2523
4228
  return {
2524
- v: typeof b.v === "number" ? b.v : 1,
2525
- appId: b.appId,
4229
+ v: 2,
4230
+ appId: body.appId,
4231
+ appIncarnation: body.appIncarnation,
4232
+ jobId: body.jobId,
2526
4233
  trigger: {
2527
- id: typeof t.id === "string" ? t.id : "",
2528
- skill: typeof t.skill === "string" ? t.skill : "",
2529
- runAs: { agentId: t.runAs.agentId, persona: t.runAs.persona },
2530
- ...typeof t.maxDepth === "number" ? { maxDepth: t.maxDepth } : {}
4234
+ id: typeof trigger.id === "string" ? trigger.id : "",
4235
+ skill: typeof trigger.skill === "string" ? trigger.skill : "",
4236
+ runAs: {
4237
+ agentId: trigger.runAs.agentId,
4238
+ persona: trigger.runAs.persona
4239
+ },
4240
+ ...typeof trigger.maxDepth === "number" ? { maxDepth: trigger.maxDepth } : {}
2531
4241
  },
2532
- event: { ns: typeof e.ns === "string" ? e.ns : CHAT_MESSAGE_NS, id: e.id, row: e.row }
4242
+ event: {
4243
+ ns: typeof event.ns === "string" ? event.ns : CHAT_MESSAGE_NS,
4244
+ id: event.id,
4245
+ row: event.row
4246
+ }
2533
4247
  };
2534
4248
  }
2535
4249
  async function bookForChannel(db, channelId) {
2536
4250
  if (!channelId) return null;
2537
- const res = await db.query({ [BRAND_NS.book]: { $: { where: { channelId } } } });
2538
- return (res[BRAND_NS.book] ?? [])[0] ?? null;
4251
+ const result = await db.query({
4252
+ [BRAND_NS.book]: { $: { where: { channelId } } }
4253
+ });
4254
+ const books = result[BRAND_NS.book] ?? [];
4255
+ if (books.length > 1)
4256
+ throw new BrandConflictError(`multiple brand books claim channel ${channelId}`);
4257
+ return books[0] ?? null;
2539
4258
  }
2540
- function brandInputFor(body, attachments) {
4259
+ function brandInputFor(body, attachments, author, attachedAssetIds = []) {
2541
4260
  const row = body.event.row;
2542
- const prompt = `A new message arrived in this brand book's channel from ${String(row.authorId ?? "someone")}: "${String(row.body ?? "")}". Ground yourself first (read_brand_book, list_assets), then move the brand work forward: study the material, justify colors with the math tools, and park palette ideas as proposals \u2014 a human must explicitly approve before you resolve or compile. If you reply in chat, keep it concise.`;
4261
+ const actor = author ? `${author.displayName} (${author.kind}${author.managerDisplayName ? `, managed by ${author.managerDisplayName}` : ""})` : row.authorKind === "bot" ? "a managed agent" : "a participant";
4262
+ const prompt = `A new message arrived in this brand book's channel from ${actor}: "${String(row.body ?? "")}". Ground yourself first (read_brand_book, list_assets), then move the work forward. Study real material, justify colors, and park exact proposals. A human approves in the Brand surface; acceptance applies the reviewed facet and recompiles dependent tokens. ` + (attachedAssetIds.length > 0 ? `This message atomically attached Brand asset id${attachedAssetIds.length === 1 ? "" : "s"} ${attachedAssetIds.join(", ")}; use view_asset on those exact ids before drawing conclusions. ` : "") + "Keep any chat reply concise.";
2543
4263
  return attachments?.length ? [...attachments, { type: "text", text: prompt }] : prompt;
2544
4264
  }
2545
- var PRETURN_IMAGE_TYPES = /* @__PURE__ */ new Set([
2546
- "image/png",
2547
- "image/jpeg",
2548
- "image/gif",
2549
- "image/webp"
2550
- ]);
2551
- async function assetBlocksFor(db, fileBaseUrl, raw) {
2552
- if (!Array.isArray(raw)) return [];
2553
- const ids = raw.filter((v) => typeof v === "string");
2554
- if (ids.length === 0) return [];
2555
- const res = await db.query({ [BRAND_NS.asset]: { $: { where: { id: { $in: ids } } } } });
2556
- const byId = new Map((res[BRAND_NS.asset] ?? []).map((a2) => [a2.id, a2]));
2557
- const blocks = [];
2558
- for (const id of ids) {
2559
- const asset = byId.get(id);
2560
- if (!asset || asset.deletedAt != null || !PRETURN_IMAGE_TYPES.has(asset.contentType)) continue;
2561
- const url = asset.url.startsWith("http") ? asset.url : fileBaseUrl + asset.url;
2562
- blocks.push({ type: "image", source: { type: "url", url } });
2563
- }
2564
- return blocks;
2565
- }
2566
4265
  async function dispatchBrandTurn(deps, body) {
4266
+ if (body.appId !== deps.appId || body.trigger.skill !== "brand" || body.event.ns !== CHAT_MESSAGE_NS) throw new BrandForbiddenError("foreign or non-brand dispatch");
2567
4267
  const channelId = String(body.event.row.channelId ?? "");
2568
4268
  const book = await bookForChannel(deps.db, channelId);
2569
- if (!book) throw new BrandNotFoundError(`brand book for channel ${channelId || "(missing channelId)"}`);
2570
- const visionInToolResults = supportsBrandVision(deps.catalog?.[deps.model]);
4269
+ if (!book)
4270
+ throw new BrandNotFoundError(
4271
+ `brand book for channel ${channelId || "(missing channelId)"}`
4272
+ );
4273
+ const agentId = body.trigger.runAs.agentId;
4274
+ if (!book.memberIds.includes(agentId))
4275
+ throw new BrandNotFoundError(`brand book for channel ${channelId}`);
4276
+ const read = await deps.authorizeCapability({
4277
+ agentId,
4278
+ bookId: book.id,
4279
+ capability: "brand.read"
4280
+ });
4281
+ if (!read || read.capability !== "brand.read" || !read.authorityRef)
4282
+ throw new BrandForbiddenError("agent lacks live brand.read");
4283
+ const authorId = typeof body.event.row.authorId === "string" ? body.event.row.authorId : "";
4284
+ const authors = authorId ? await deps.resolvePrincipals({
4285
+ requesterAgentId: agentId,
4286
+ bookId: book.id,
4287
+ principalIds: [authorId]
4288
+ }) : [];
4289
+ const author = authors.find((candidate) => candidate.id === authorId);
4290
+ const attachedAssetIds = await attachedBrandAssetIds(
4291
+ deps.db,
4292
+ book,
4293
+ body.event.row
4294
+ );
4295
+ const runtime = await deps.agentRuntimeFor({
4296
+ appId: body.appId,
4297
+ agentId,
4298
+ bookId: book.id,
4299
+ triggerId: body.trigger.id,
4300
+ eventId: body.event.id,
4301
+ jobId: body.jobId
4302
+ });
4303
+ if (!runtime.credentialRef || !runtime.jobId || runtime.jobId !== body.jobId)
4304
+ throw new BrandForbiddenError("agent data credential is not bound");
4305
+ let durableOutcome = false;
4306
+ const bridge = {
4307
+ ...runtime.bridge,
4308
+ createProposal: async (input) => {
4309
+ const proposal = await runtime.bridge.createProposal(input);
4310
+ durableOutcome = true;
4311
+ return proposal;
4312
+ },
4313
+ recordAssetAnalysis: async (input) => {
4314
+ const asset = await runtime.bridge.recordAssetAnalysis(input);
4315
+ durableOutcome = true;
4316
+ return asset;
4317
+ }
4318
+ };
4319
+ const visionInToolResults = supportsBrandVision(
4320
+ deps.catalog?.[deps.model]
4321
+ );
2571
4322
  const self = {
2572
- selfId: body.trigger.runAs.agentId,
4323
+ selfId: agentId,
2573
4324
  kind: "bot",
2574
4325
  displayName: body.trigger.runAs.persona
2575
4326
  };
2576
4327
  const persona = createBrandPersona({
2577
4328
  model: deps.model,
2578
- brand: { db: deps.db, bookId: book.id, self, fileBaseUrl: deps.fileBaseUrl, visionInToolResults },
4329
+ brand: {
4330
+ db: runtime.db,
4331
+ bookId: book.id,
4332
+ self,
4333
+ agentDbBinding: {
4334
+ principalId: agentId,
4335
+ credentialRef: runtime.credentialRef
4336
+ },
4337
+ agentJobId: runtime.jobId,
4338
+ agentBridge: bridge,
4339
+ authorizeCapability: deps.authorizeCapability,
4340
+ resolvePrincipals: deps.resolvePrincipals,
4341
+ visionInToolResults,
4342
+ ...deps.newId ? { newId: deps.newId } : {}
4343
+ },
2579
4344
  skills: deps.chatSkillFor ? [deps.chatSkillFor(channelId, self)] : []
2580
4345
  });
2581
- const attachments = visionInToolResults ? [] : await assetBlocksFor(deps.db, deps.fileBaseUrl, body.event.row.assetIds);
2582
- const run = await deps.runAgent(deps.inference, persona, { input: brandInputFor(body, attachments) });
2583
- return { finalText: run.finalText };
4346
+ const run = await deps.runAgent(deps.inference, persona, {
4347
+ // Models without tool-result vision receive no raw/signed fallback URL.
4348
+ input: brandInputFor(body, void 0, author, attachedAssetIds)
4349
+ });
4350
+ return { finalText: run.finalText, durableOutcome };
2584
4351
  }
2585
4352
 
2586
4353
  // src/descriptor.ts
@@ -2608,10 +4375,12 @@ var brandIntegration = {
2608
4375
  schema: BRAND_SCHEMA,
2609
4376
  rules: brandRules(),
2610
4377
  triggers: [],
4378
+ agentProfile: BRAND_AGENT_PROFILE,
2611
4379
  provision: {
2612
4380
  human: [
4381
+ "Configure direct Clerk human approval through authorizeCapability + consumeHumanExact; delegated, machine, and agent principals must not resolve proposals.",
2613
4382
  'Choose the trigger mode: one global mention-gated bot (brandBotTrigger({ mention: "@brand" }), no channels) or per-book channel-scoped bots (channels: [book.channelId], no mention).',
2614
- "Pick the bot's model; when supportsBrandVision(catalog[model]) is false, dispatch attaches assets pre-turn instead of inside tool results.",
4383
+ "Pick a model where supportsBrandVision(catalog[model]) is true for in-conversation asset viewing; secure dispatch never falls back to signed or persistent asset URLs.",
2615
4384
  "Decide whether compiled tokens are public (publicTokens serves tokens.css/tokens.json unauthenticated)."
2616
4385
  ],
2617
4386
  cli: [
@@ -2619,10 +4388,14 @@ var brandIntegration = {
2619
4388
  "Install brandRules() at /app/:id/admin/rules (merged with the app's existing rules).",
2620
4389
  "Register a brandBotTrigger(...) per bot at /app/:id/admin/triggers \u2014 global mention-gated, or channel-scoped per book.",
2621
4390
  "Seed the bot's agent id into each brand_book.memberIds roster (audienceFanoutOps covers existing child rows).",
2622
- "Provision the bot's AI provider secret (via @odla-ai/ai) and the worker's admin odla-db credential."
4391
+ "Issue the bot brand.read + brand.edit and expose only BRAND_AGENT_PROFILE semantic operations; deny raw brand_* writes and raw file operations.",
4392
+ "Provision private file signing and verifySourceAssetSnapshot; never expose an admin credential or persistent asset URL to the agent."
2623
4393
  ],
2624
4394
  doctor: [
2625
- "All five brand_* namespaces have rules installed (brand_asset fully closed \u2014 worker-mediated only).",
4395
+ "All six brand_* namespaces have rules installed; raw browser/agent writes are denied and child reads use current linked-book membership.",
4396
+ "The agent runtime matches BRAND_AGENT_PROFILE: brand.read/edit only, semantic operations only, no raw brand_* or file access.",
4397
+ "Proposal decisions consume a direct-human exact brand.approve authority use and persist one guarded brand_approval_receipt.",
4398
+ "Private source assets are signed briefly and verifySourceAssetSnapshot revalidates path, ETag, size, content type, and digest before approval.",
2626
4399
  "brand_book.id/slug, brand_section.key, and the other mirrored id attrs are unique.",
2627
4400
  "Each registered trigger's agent id is present in its target books' memberIds rosters.",
2628
4401
  "Books with channelId set point at real chat channels when @odla-ai/chat is installed."