@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.js CHANGED
@@ -59,6 +59,7 @@ var BRAND_NS = {
59
59
  section: "brand_section",
60
60
  palette: "brand_palette",
61
61
  proposal: "brand_proposal",
62
+ approvalReceipt: "brand_approval_receipt",
62
63
  asset: "brand_asset"
63
64
  };
64
65
  var BOOK_STATUSES = ["draft", "active", "archived"];
@@ -66,9 +67,9 @@ var SECTION_KINDS = ["palette", "typography", "voice", "logo", "imagery"];
66
67
  var SECTION_STATUSES = ["draft", "approved"];
67
68
  var PALETTE_STATUSES = ["active", "archived"];
68
69
  var PALETTE_SOURCES = ["extracted", "derived", "manual"];
69
- var PROPOSAL_KINDS = ["palette", "typography", "voice", "logo"];
70
+ var PROPOSAL_KINDS = SECTION_KINDS;
70
71
  var PROPOSAL_STATUSES = ["open", "accepted", "rejected", "superseded"];
71
- var ASSET_KINDS = ["logo", "wordmark", "inspiration", "document", "font", "other"];
72
+ var ASSET_KINDS = ["logo", "wordmark", "inspiration", "document", "other"];
72
73
  var SWATCH_ROLES = [
73
74
  "primary",
74
75
  "secondary",
@@ -84,6 +85,25 @@ var SWATCH_ROLES = [
84
85
  "custom"
85
86
  ];
86
87
 
88
+ // src/agent-profile.ts
89
+ var BRAND_AGENT_PROFILE = {
90
+ version: 1,
91
+ projectCapabilities: ["brand.read", "brand.edit"],
92
+ semanticOperations: [
93
+ "brand.proposal.create",
94
+ "brand.asset.analysis.record",
95
+ "brand.asset.content.read"
96
+ ],
97
+ rawBrandWrites: false,
98
+ rawFileOperations: false,
99
+ approval: {
100
+ principalKind: "human",
101
+ projectCapability: "brand.approve",
102
+ capability: "brand.proposal.resolve",
103
+ exactAction: true
104
+ }
105
+ };
106
+
87
107
  // src/errors.ts
88
108
  var BrandInputError = class extends Error {
89
109
  fields;
@@ -99,6 +119,31 @@ var BrandNotFoundError = class extends Error {
99
119
  this.name = "BrandNotFoundError";
100
120
  }
101
121
  };
122
+ var BrandGoneError = class extends Error {
123
+ constructor(message = "brand authority or resource is no longer available") {
124
+ super(message);
125
+ this.name = "BrandGoneError";
126
+ }
127
+ };
128
+ var BrandForbiddenError = class extends Error {
129
+ constructor(message = "forbidden") {
130
+ super(message);
131
+ this.name = "BrandForbiddenError";
132
+ }
133
+ };
134
+ var BrandConflictError = class extends Error {
135
+ constructor(message) {
136
+ super(message);
137
+ this.name = "BrandConflictError";
138
+ }
139
+ };
140
+ var BrandReviewStateChangedError = class extends BrandConflictError {
141
+ code = "brand_review_state_changed";
142
+ constructor(message = "proposal or live brand dependencies changed; review and approve again") {
143
+ super(message);
144
+ this.name = "BrandReviewStateChangedError";
145
+ }
146
+ };
102
147
 
103
148
  // src/deps.ts
104
149
  async function defaultFetchBytes(url) {
@@ -134,17 +179,21 @@ var BRAND_SCHEMA = {
134
179
  [BRAND_NS.book]: {
135
180
  attrs: {
136
181
  id: uniq("string"),
182
+ version: a("number"),
137
183
  slug: uniq("string"),
138
184
  name: idx("string"),
139
185
  status: idx("string"),
140
186
  // draft | active | archived
141
187
  ownerId: idx("string"),
188
+ createdAuthorityRef: idx("string"),
189
+ rosterAuthorityRef: opt("string"),
142
190
  memberIds: a("json"),
143
191
  // the auth roster, incl. the bot agent id
144
- channelId: idx("string", true),
192
+ channelId: a("string", { unique: true, indexed: true, optional: true }),
145
193
  activePaletteId: opt("string"),
194
+ activationRevision: a("number"),
146
195
  tokens: opt("json"),
147
- // { light, dark, warnings, compiledAt }
196
+ // maps + complete palette/typography receipt provenance
148
197
  summary: opt("string"),
149
198
  createdAt: idx("date"),
150
199
  updatedAt: idx("date")
@@ -162,7 +211,9 @@ var BRAND_SCHEMA = {
162
211
  content: a("json"),
163
212
  audience: a("json"),
164
213
  updatedBy: a("string"),
165
- updatedAt: idx("date")
214
+ updatedAt: idx("date"),
215
+ approvalReceiptId: opt("string"),
216
+ approvalActionDigest: opt("string")
166
217
  }
167
218
  },
168
219
  [BRAND_NS.palette]: {
@@ -178,7 +229,9 @@ var BRAND_SCHEMA = {
178
229
  source: a("string"),
179
230
  // extracted | derived | manual
180
231
  rationale: opt("string"),
181
- proposalId: opt("string"),
232
+ proposalId: idx("string"),
233
+ approvalReceiptId: a("string"),
234
+ approvalActionDigest: a("string"),
182
235
  audience: a("json"),
183
236
  createdAt: idx("date"),
184
237
  updatedAt: idx("date")
@@ -194,14 +247,45 @@ var BRAND_SCHEMA = {
194
247
  // open | accepted | rejected | superseded
195
248
  payload: a("json"),
196
249
  rationale: a("string"),
197
- sourceAssetId: opt("string"),
198
- messageId: opt("string"),
250
+ provenance: a("json"),
251
+ // exact source asset/message/turn ids (nullable asset)
252
+ reviewDigest: idx("string"),
199
253
  audience: a("json"),
200
254
  createdBy: a("string"),
255
+ createdAuthorityRef: a("string"),
201
256
  createdAt: idx("date"),
202
257
  resolvedBy: opt("string"),
203
258
  resolvedAt: opt("date"),
204
- resolutionNote: opt("string")
259
+ resolutionNote: opt("string"),
260
+ approvedBy: opt("string"),
261
+ appliedBy: opt("string"),
262
+ resolutionReceiptId: opt("string"),
263
+ resolutionActionDigest: opt("string")
264
+ }
265
+ },
266
+ [BRAND_NS.approvalReceipt]: {
267
+ attrs: {
268
+ id: uniq("string"),
269
+ version: a("number"),
270
+ mutationKey: uniq("string"),
271
+ bookId: idx("string"),
272
+ proposalId: idx("string"),
273
+ resolution: idx("string"),
274
+ // accepted | rejected
275
+ paletteId: opt("string"),
276
+ resolutionNote: opt("string"),
277
+ reviewedProposal: a("json"),
278
+ actionDigest: idx("string"),
279
+ decisionBinding: a("json"),
280
+ approvedBy: idx("string"),
281
+ approvedByKind: a("string"),
282
+ appliedBy: idx("string"),
283
+ appliedByKind: a("string"),
284
+ authorityRef: idx("string"),
285
+ authorityCapability: a("string"),
286
+ authorityConsumption: a("json"),
287
+ createdAt: idx("date"),
288
+ receiptDigest: idx("string")
205
289
  }
206
290
  },
207
291
  [BRAND_NS.asset]: {
@@ -209,59 +293,94 @@ var BRAND_SCHEMA = {
209
293
  id: uniq("string"),
210
294
  bookId: idx("string"),
211
295
  kind: idx("string"),
212
- // logo | wordmark | inspiration | document | font | other
296
+ // logo | wordmark | inspiration | document | other
213
297
  path: idx("string"),
214
- url: a("string"),
298
+ storageObjectId: idx("string"),
299
+ contentDigest: idx("string"),
215
300
  contentType: a("string"),
216
301
  size: a("number"),
302
+ status: idx("string"),
303
+ // live | deleting | deleted
217
304
  title: opt("string"),
218
305
  analysis: opt("json"),
219
306
  // { description, dominantColors: hex[], tags }
220
307
  analyzedAt: opt("date"),
308
+ analysisRevision: a("number"),
309
+ analysisDigest: opt("string"),
310
+ analyzedBy: opt("string"),
311
+ analyzedAuthorityRef: opt("string"),
221
312
  audience: a("json"),
222
313
  uploadedBy: a("string"),
314
+ uploadedAuthorityRef: a("string"),
223
315
  createdAt: idx("date"),
224
- deletedAt: opt("date")
316
+ deletedAt: opt("date"),
225
317
  // tombstone — asset rows are never row-deleted
318
+ deletedBy: opt("string"),
319
+ deletedAuthorityRef: opt("string")
226
320
  }
227
321
  }
228
322
  },
229
- links: {}
323
+ links: {
324
+ brandSectionBook: {
325
+ forward: { on: BRAND_NS.section, has: "one", label: "book" },
326
+ reverse: { on: BRAND_NS.book, has: "many", label: "sections" }
327
+ },
328
+ brandPaletteBook: {
329
+ forward: { on: BRAND_NS.palette, has: "one", label: "book" },
330
+ reverse: { on: BRAND_NS.book, has: "many", label: "palettes" }
331
+ },
332
+ brandProposalBook: {
333
+ forward: { on: BRAND_NS.proposal, has: "one", label: "book" },
334
+ reverse: { on: BRAND_NS.book, has: "many", label: "proposals" }
335
+ },
336
+ brandApprovalReceiptBook: {
337
+ forward: { on: BRAND_NS.approvalReceipt, has: "one", label: "book" },
338
+ reverse: { on: BRAND_NS.book, has: "many", label: "approvalReceipts" }
339
+ },
340
+ brandAssetBook: {
341
+ forward: { on: BRAND_NS.asset, has: "one", label: "book" },
342
+ reverse: { on: BRAND_NS.book, has: "many", label: "assets" }
343
+ }
344
+ }
230
345
  };
231
346
 
232
347
  // src/rules.ts
233
- var AUDIENCE = "auth.id in data.audience";
348
+ var CURRENT_BOOK_MEMBER = "ref('book.memberIds').exists(members, auth.id in members)";
234
349
  var BRAND_RULES = {
235
350
  [BRAND_NS.book]: {
236
351
  view: "auth.id in data.memberIds",
237
- // Creator is the owner and must include itself in the roster.
238
- create: "auth.signedIn && auth.id == data.ownerId && auth.id in data.memberIds",
239
- update: "auth.id == data.ownerId",
240
- delete: "auth.id == data.ownerId"
352
+ // Every mutation is worker-routed: CEL cannot distinguish an update from
353
+ // a retract, so effect-bearing fields must never be browser-writable.
354
+ create: "false",
355
+ update: "false",
356
+ delete: "false"
241
357
  },
242
358
  [BRAND_NS.section]: {
243
- view: AUDIENCE,
244
- create: AUDIENCE,
245
- update: AUDIENCE,
359
+ view: CURRENT_BOOK_MEMBER,
360
+ create: "false",
361
+ update: "false",
246
362
  delete: "false"
247
- // sections are upserted in place, never removed
248
363
  },
249
364
  [BRAND_NS.palette]: {
250
- view: AUDIENCE,
251
- create: AUDIENCE,
252
- update: AUDIENCE,
365
+ view: CURRENT_BOOK_MEMBER,
366
+ create: "false",
367
+ update: "false",
253
368
  delete: "false"
254
- // archive via status, keep provenance
255
369
  },
256
370
  [BRAND_NS.proposal]: {
257
- view: AUDIENCE,
258
- create: AUDIENCE,
259
- update: AUDIENCE,
371
+ view: CURRENT_BOOK_MEMBER,
372
+ create: "false",
373
+ update: "false",
374
+ delete: "false"
375
+ },
376
+ [BRAND_NS.approvalReceipt]: {
377
+ view: CURRENT_BOOK_MEMBER,
378
+ create: "false",
379
+ update: "false",
260
380
  delete: "false"
261
- // resolution history is the audit trail
262
381
  },
263
382
  [BRAND_NS.asset]: {
264
- view: "false",
383
+ view: CURRENT_BOOK_MEMBER,
265
384
  create: "false",
266
385
  update: "false",
267
386
  delete: "false"
@@ -278,7 +397,6 @@ var ASSET_CONTENT_TYPES = /* @__PURE__ */ new Set([
278
397
  "image/jpeg",
279
398
  "image/gif",
280
399
  "image/webp",
281
- "image/svg+xml",
282
400
  "application/pdf"
283
401
  ]);
284
402
  var HEX_RGB = /^#[0-9a-f]{3}$/;
@@ -395,8 +513,11 @@ function assertSectionContent(kind, content) {
395
513
  }
396
514
  function safeFileName(name) {
397
515
  if (typeof name !== "string") throw new BrandInputError("file name must be a string");
398
- const cleaned = name.replace(/[/\\]/g, "").replace(/[\u0000-\u001f\u007f]/g, "").trim().replace(/^\.+/, "");
399
- if (cleaned === "") throw new BrandInputError("file name is empty after sanitizing");
516
+ if (/[/\\?#%\u0000-\u001f\u007f]/.test(name))
517
+ throw new BrandInputError("file name must not contain path or URL delimiter characters");
518
+ const cleaned = name.trim().replace(/^\.+/, "");
519
+ if (cleaned === "" || cleaned === "." || cleaned === "..")
520
+ throw new BrandInputError("file name is empty or a dot segment after sanitizing");
400
521
  return cleaned.slice(0, 120);
401
522
  }
402
523
  function assertAssetContentType(value) {
@@ -421,6 +542,237 @@ function assertAnalysis(value) {
421
542
  };
422
543
  }
423
544
 
545
+ // src/review-json.ts
546
+ var record = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
547
+ function isBoundedBrandJson(root, limits = {}) {
548
+ const maxDepth = limits.maxDepth ?? 12;
549
+ const maxNodes = limits.maxNodes ?? 2048;
550
+ const maxBytes = limits.maxBytes ?? 32 * 1024;
551
+ const stack = [{
552
+ value: root,
553
+ depth: 0
554
+ }];
555
+ const seen = /* @__PURE__ */ new Set();
556
+ let nodes = 0;
557
+ while (stack.length) {
558
+ const { value, depth } = stack.pop();
559
+ if (++nodes > maxNodes || depth > maxDepth) return false;
560
+ if (value === null || typeof value === "string" || typeof value === "boolean") continue;
561
+ if (typeof value === "number") {
562
+ if (!Number.isFinite(value)) return false;
563
+ continue;
564
+ }
565
+ if (typeof value !== "object" || seen.has(value)) return false;
566
+ seen.add(value);
567
+ if (Array.isArray(value)) {
568
+ for (const child of value)
569
+ stack.push({ value: child, depth: depth + 1 });
570
+ } else if (record(value)) {
571
+ for (const child of Object.values(value))
572
+ stack.push({ value: child, depth: depth + 1 });
573
+ } else {
574
+ return false;
575
+ }
576
+ }
577
+ try {
578
+ return new TextEncoder().encode(JSON.stringify(root)).byteLength <= maxBytes;
579
+ } catch {
580
+ return false;
581
+ }
582
+ }
583
+ function canonical(value) {
584
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
585
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
586
+ const row = value;
587
+ return `{${Object.keys(row).sort().map((key) => `${JSON.stringify(key)}:${canonical(row[key])}`).join(",")}}`;
588
+ }
589
+ function canonicalBrandJson(value) {
590
+ if (!isBoundedBrandJson(value, {
591
+ maxDepth: 20,
592
+ maxNodes: 8192,
593
+ maxBytes: 128 * 1024
594
+ })) throw new TypeError("brand digest input must be bounded finite JSON");
595
+ return canonical(value);
596
+ }
597
+ async function brandJsonDigest(value) {
598
+ const bytes = await crypto.subtle.digest(
599
+ "SHA-256",
600
+ new TextEncoder().encode(canonicalBrandJson(value))
601
+ );
602
+ return `sha256:${[...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
603
+ }
604
+
605
+ // src/review.ts
606
+ var DIGEST = /^sha256:[0-9a-f]{64}$/;
607
+ var record2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
608
+ var exactKeys = (value, required, optional = []) => {
609
+ const allowed = /* @__PURE__ */ new Set([...required, ...optional]);
610
+ return required.every((key) => Object.hasOwn(value, key)) && Object.keys(value).every((key) => allowed.has(key));
611
+ };
612
+ var boundedString = (value, max = 200) => typeof value === "string" && value.length > 0 && value.length <= max;
613
+ var safeTime = (value) => Number.isSafeInteger(value) && value >= 0;
614
+ function brandAuthorityRef(consumption) {
615
+ return `authority:${consumption.grantId}:v${consumption.grantVersion}`;
616
+ }
617
+ var SNAPSHOT_REQUIRED = [
618
+ "id",
619
+ "bookId",
620
+ "kind",
621
+ "status",
622
+ "payload",
623
+ "rationale",
624
+ "provenance",
625
+ "reviewDigest",
626
+ "audience",
627
+ "createdBy",
628
+ "createdAuthorityRef",
629
+ "createdAt"
630
+ ];
631
+ function validSnapshotShape(value) {
632
+ if (!record2(value) || !exactKeys(value, SNAPSHOT_REQUIRED)) return false;
633
+ const provenance = value.provenance;
634
+ 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, [
635
+ "sourceAssetId",
636
+ "sourceAsset",
637
+ "messageId",
638
+ "turnId",
639
+ "taintLabels"
640
+ ]) && (provenance.sourceAssetId === null || boundedString(provenance.sourceAssetId)) && (provenance.sourceAsset === null || record2(provenance.sourceAsset) && exactKeys(provenance.sourceAsset, [
641
+ "assetId",
642
+ "contentDigest",
643
+ "objectEtag",
644
+ "objectSize",
645
+ "pathDigest",
646
+ "contentType",
647
+ "analysisRevision",
648
+ "analysisDigest"
649
+ ]) && 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);
650
+ }
651
+ var CONSUMPTION_REQUIRED = [
652
+ "id",
653
+ "grantId",
654
+ "grantVersion",
655
+ "useNumber",
656
+ "actorPrincipalId",
657
+ "actorKind",
658
+ "credentialId",
659
+ "credentialKind",
660
+ "appId",
661
+ "appIncarnation",
662
+ "capability",
663
+ "projectCapability",
664
+ "effect",
665
+ "actionDigest",
666
+ "resourceDigest",
667
+ "constraintEvidence",
668
+ "consumptionIdempotencyKey",
669
+ "requestDigest",
670
+ "consumedAt"
671
+ ];
672
+ function isBrandHumanAuthorityConsumption(value) {
673
+ if (!record2(value) || !exactKeys(value, CONSUMPTION_REQUIRED)) return false;
674
+ 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);
675
+ }
676
+ var RECEIPT_REQUIRED = [
677
+ "version",
678
+ "id",
679
+ "mutationKey",
680
+ "bookId",
681
+ "proposalId",
682
+ "resolution",
683
+ "reviewedProposal",
684
+ "actionDigest",
685
+ "decisionBinding",
686
+ "approvedBy",
687
+ "approvedByKind",
688
+ "appliedBy",
689
+ "appliedByKind",
690
+ "authorityRef",
691
+ "authorityCapability",
692
+ "authorityConsumption",
693
+ "createdAt",
694
+ "receiptDigest"
695
+ ];
696
+ async function verifyBrandApprovalReceipt(receipt2) {
697
+ try {
698
+ if (!isBoundedBrandJson(receipt2, { maxDepth: 14, maxNodes: 2500, maxBytes: 48 * 1024 }))
699
+ return false;
700
+ if (!record2(receipt2) || !exactKeys(receipt2, RECEIPT_REQUIRED, ["paletteId", "resolutionNote"]))
701
+ return false;
702
+ const binding = receipt2.decisionBinding;
703
+ 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, [
704
+ "version",
705
+ "bookVersion",
706
+ "activationRevision",
707
+ "activePaletteId",
708
+ "memberIdsDigest",
709
+ "activePaletteDigest",
710
+ "typographyDigest",
711
+ "sourceAssetDigest",
712
+ "effectDigest"
713
+ ]) || 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;
714
+ const reviewed = receipt2.reviewedProposal;
715
+ const authority = receipt2.authorityConsumption;
716
+ 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;
717
+ const { reviewDigest, ...unsignedReview } = reviewed;
718
+ if (await brandJsonDigest(unsignedReview) !== reviewDigest) return false;
719
+ const expectedAction = await brandJsonDigest({
720
+ version: 1,
721
+ reviewedProposal: reviewed,
722
+ resolution: receipt2.resolution,
723
+ resolutionNote: receipt2.resolutionNote ?? null,
724
+ paletteId: receipt2.paletteId ?? null,
725
+ decisionBinding: binding
726
+ });
727
+ if (expectedAction !== receipt2.actionDigest) return false;
728
+ if (await brandJsonDigest({ bookId: receipt2.bookId, proposalId: receipt2.proposalId }) !== authority.resourceDigest) return false;
729
+ const { receiptDigest, ...immutable } = receipt2;
730
+ return await brandJsonDigest(immutable) === receiptDigest;
731
+ } catch {
732
+ return false;
733
+ }
734
+ }
735
+
736
+ // src/discussion-reference.ts
737
+ var BRAND_DISCUSSION_REFERENCE_KINDS = [
738
+ "brand:book",
739
+ "brand:asset",
740
+ "brand:palette",
741
+ "brand:proposal",
742
+ "brand:receipt"
743
+ ];
744
+ var SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,199}$/;
745
+ function brandDiscussionReferenceId(target) {
746
+ return target.resourceId ? `${target.bookId}/${target.resourceId}` : target.bookId;
747
+ }
748
+ function formatBrandDiscussionReference(target) {
749
+ return `${target.kind}/${brandDiscussionReferenceId(target)}`;
750
+ }
751
+ function parseBrandDiscussionReference(input) {
752
+ let raw;
753
+ try {
754
+ raw = input instanceof URL ? input.searchParams.get("odla-ref") : new URL(input, "https://brand.invalid").searchParams.get("odla-ref");
755
+ } catch {
756
+ return null;
757
+ }
758
+ if (!raw) return null;
759
+ const [kind, bookId, resourceId, extra] = raw.split("/");
760
+ if (extra !== void 0 || !BRAND_DISCUSSION_REFERENCE_KINDS.includes(
761
+ kind
762
+ ) || !bookId || !SEGMENT.test(bookId)) return null;
763
+ const typedKind = kind;
764
+ if (typedKind === "brand:book") {
765
+ return resourceId === void 0 ? { kind: typedKind, bookId } : null;
766
+ }
767
+ return resourceId && SEGMENT.test(resourceId) ? { kind: typedKind, bookId, resourceId } : null;
768
+ }
769
+ function brandDiscussionReferenceHref(current, target, basePath = "/") {
770
+ const next = new URL(current.origin);
771
+ next.pathname = basePath.startsWith("/") ? basePath : `/${basePath}`;
772
+ next.searchParams.set("odla-ref", formatBrandDiscussionReference(target));
773
+ return next.toString();
774
+ }
775
+
424
776
  // src/ops/books.ts
425
777
  var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
426
778
  function createBookOps(input) {
@@ -428,7 +780,11 @@ function createBookOps(input) {
428
780
  if (!SLUG_RE.test(slug))
429
781
  throw new BrandInputError("slug must be lowercase letters, digits, and inner hyphens");
430
782
  const name = capString(input.name, "name", 120);
431
- const memberIds = Array.from(/* @__PURE__ */ new Set([input.ownerId, ...input.memberIds]));
783
+ const ownerId = capString(input.ownerId, "ownerId", 160);
784
+ const createdAuthorityRef = capString(input.createdAuthorityRef, "createdAuthorityRef", 200);
785
+ const memberIds = Array.from(/* @__PURE__ */ new Set([ownerId, ...input.memberIds.map((id, index) => capString(id, `memberIds[${index}]`, 160))]));
786
+ if (memberIds.length > 100)
787
+ throw new BrandInputError("a brand book supports at most 100 members");
432
788
  return [
433
789
  {
434
790
  t: "update",
@@ -436,11 +792,14 @@ function createBookOps(input) {
436
792
  id: input.id,
437
793
  attrs: {
438
794
  id: input.id,
795
+ version: 1,
439
796
  slug,
440
797
  name,
441
798
  status: "draft",
442
- ownerId: input.ownerId,
799
+ ownerId,
800
+ createdAuthorityRef,
443
801
  memberIds,
802
+ activationRevision: 0,
444
803
  createdAt: input.now,
445
804
  updatedAt: input.now,
446
805
  ...input.channelId ? { channelId: input.channelId } : {}
@@ -448,8 +807,8 @@ function createBookOps(input) {
448
807
  }
449
808
  ];
450
809
  }
451
- var PATCHABLE = /* @__PURE__ */ new Set(["name", "status", "channelId", "summary", "activePaletteId", "tokens"]);
452
- var CLEARABLE = /* @__PURE__ */ new Set(["channelId", "summary", "activePaletteId", "tokens"]);
810
+ var PATCHABLE = /* @__PURE__ */ new Set(["name", "status", "channelId", "summary"]);
811
+ var CLEARABLE = /* @__PURE__ */ new Set(["channelId", "summary"]);
453
812
  function validateBookField(key, value) {
454
813
  switch (key) {
455
814
  case "name":
@@ -462,13 +821,8 @@ function validateBookField(key, value) {
462
821
  return capString(value, "channelId", 128);
463
822
  case "summary":
464
823
  return capString(value, "summary", 2e3);
465
- case "activePaletteId":
466
- return capString(value, "activePaletteId", 128);
467
- default: {
468
- if (typeof value !== "object" || value === null || Array.isArray(value))
469
- throw new BrandInputError("tokens must be an object");
824
+ default:
470
825
  return value;
471
- }
472
826
  }
473
827
  }
474
828
  function updateBookOps(bookId, patch, now) {
@@ -515,6 +869,7 @@ function upsertSectionOps(input) {
515
869
  if (!SECTION_STATUSES.includes(status))
516
870
  throw new BrandInputError(`status must be one of: ${SECTION_STATUSES.join(", ")}`);
517
871
  const content = assertSectionContent(input.kind, input.content);
872
+ if (status === "approved" && (!input.approvalReceiptId || !input.approvalActionDigest)) throw new BrandInputError("approved sections require approval receipt provenance");
518
873
  const key = sectionKey(input.bookId, input.kind);
519
874
  return [
520
875
  {
@@ -529,47 +884,105 @@ function upsertSectionOps(input) {
529
884
  content,
530
885
  audience: input.audience,
531
886
  updatedBy: input.updatedBy,
532
- updatedAt: input.now
887
+ updatedAt: input.now,
888
+ ...input.approvalReceiptId ? { approvalReceiptId: capString(input.approvalReceiptId, "approvalReceiptId", 200) } : {},
889
+ ...input.approvalActionDigest ? { approvalActionDigest: capString(input.approvalActionDigest, "approvalActionDigest", 80) } : {}
533
890
  }
891
+ },
892
+ {
893
+ t: "link",
894
+ ns: BRAND_NS.section,
895
+ id: { ns: BRAND_NS.section, attr: "key", value: key },
896
+ label: "book",
897
+ target: input.bookId
534
898
  }
535
899
  ];
536
900
  }
901
+ async function proposeSectionOps(input) {
902
+ if (input.taintLabels.length > 16)
903
+ throw new BrandInputError("taintLabels must have at most 16 entries");
904
+ const proposal = {
905
+ id: input.id,
906
+ bookId: input.bookId,
907
+ kind: input.kind,
908
+ status: "open",
909
+ payload: { content: assertSectionContent(input.kind, input.content) },
910
+ rationale: capString(input.rationale, "rationale", 2e3),
911
+ provenance: {
912
+ sourceAssetId: input.sourceAsset?.assetId ?? null,
913
+ sourceAsset: input.sourceAsset ?? null,
914
+ messageId: capString(input.messageId, "messageId", 200),
915
+ turnId: capString(input.turnId, "turnId", 200),
916
+ taintLabels: [...new Set(input.taintLabels.map((label, index) => capString(label, `taintLabels[${index}]`, 120)))].sort()
917
+ },
918
+ audience: input.audience,
919
+ createdBy: input.createdBy,
920
+ createdAuthorityRef: input.createdAuthorityRef,
921
+ createdAt: input.now
922
+ };
923
+ return [
924
+ {
925
+ t: "update",
926
+ ns: BRAND_NS.proposal,
927
+ id: input.id,
928
+ attrs: { ...proposal, reviewDigest: await brandJsonDigest(proposal) }
929
+ },
930
+ { t: "link", ns: BRAND_NS.proposal, id: input.id, label: "book", target: input.bookId }
931
+ ];
932
+ }
537
933
 
538
934
  // src/ops/palettes.ts
539
- function proposePaletteOps(input) {
935
+ async function proposePaletteOps(input) {
540
936
  const name = capString(input.name, "name", 120);
541
937
  const rationale = capString(input.rationale, "rationale", 2e3);
542
938
  const swatches = assertSwatches(input.swatches);
543
939
  const seedHex = input.seedHex === void 0 ? void 0 : assertHex(input.seedHex, "seedHex");
940
+ if (input.taintLabels.length > 16)
941
+ throw new BrandInputError("taintLabels must have at most 16 entries");
544
942
  const payload = {
545
943
  name,
546
944
  swatches,
547
945
  ...seedHex ? { seedHex } : {},
548
946
  ...input.contrastReport !== void 0 ? { contrastReport: input.contrastReport } : {}
549
947
  };
948
+ const proposal = {
949
+ id: input.id,
950
+ bookId: input.bookId,
951
+ kind: "palette",
952
+ status: "open",
953
+ payload,
954
+ rationale,
955
+ provenance: {
956
+ sourceAssetId: input.sourceAsset?.assetId ?? null,
957
+ sourceAsset: input.sourceAsset ?? null,
958
+ messageId: capString(input.messageId, "messageId", 200),
959
+ turnId: capString(input.turnId, "turnId", 200),
960
+ taintLabels: [...new Set(input.taintLabels.map((label, index) => capString(label, `taintLabels[${index}]`, 120)))].sort()
961
+ },
962
+ audience: input.audience,
963
+ createdBy: input.createdBy,
964
+ createdAuthorityRef: input.createdAuthorityRef,
965
+ createdAt: input.now
966
+ };
550
967
  return [
551
968
  {
552
969
  t: "update",
553
970
  ns: BRAND_NS.proposal,
554
971
  id: input.id,
555
- attrs: {
556
- id: input.id,
557
- bookId: input.bookId,
558
- kind: "palette",
559
- status: "open",
560
- payload,
561
- rationale,
562
- audience: input.audience,
563
- createdBy: input.createdBy,
564
- createdAt: input.now,
565
- ...input.sourceAssetId ? { sourceAssetId: input.sourceAssetId } : {},
566
- ...input.messageId ? { messageId: input.messageId } : {}
567
- }
568
- }
972
+ attrs: { ...proposal, reviewDigest: await brandJsonDigest(proposal) }
973
+ },
974
+ { t: "link", ns: BRAND_NS.proposal, id: input.id, label: "book", target: input.bookId }
569
975
  ];
570
976
  }
571
977
  function acceptProposalOps(input) {
572
- const { proposal, paletteId, resolvedBy, now } = input;
978
+ const {
979
+ proposal,
980
+ paletteId,
981
+ resolvedBy,
982
+ approvalReceiptId,
983
+ approvalActionDigest,
984
+ now
985
+ } = input;
573
986
  if (proposal.kind !== "palette")
574
987
  throw new BrandInputError(`proposal ${proposal.id} is a ${proposal.kind} proposal, not a palette`);
575
988
  if (proposal.status !== "open")
@@ -578,7 +991,7 @@ function acceptProposalOps(input) {
578
991
  const name = capString(payload.name, "payload.name", 120);
579
992
  const swatches = assertSwatches(payload.swatches);
580
993
  const seedHex = payload.seedHex === void 0 ? void 0 : assertHex(payload.seedHex, "payload.seedHex");
581
- const source = proposal.sourceAssetId ? "extracted" : seedHex ? "derived" : "manual";
994
+ const source = proposal.provenance.sourceAsset ? "extracted" : seedHex ? "derived" : "manual";
582
995
  return [
583
996
  {
584
997
  t: "update",
@@ -593,12 +1006,19 @@ function acceptProposalOps(input) {
593
1006
  source,
594
1007
  rationale: proposal.rationale,
595
1008
  proposalId: proposal.id,
1009
+ approvalReceiptId: capString(approvalReceiptId, "approvalReceiptId", 200),
1010
+ approvalActionDigest: capString(
1011
+ approvalActionDigest,
1012
+ "approvalActionDigest",
1013
+ 80
1014
+ ),
596
1015
  audience: proposal.audience,
597
1016
  createdAt: now,
598
1017
  updatedAt: now,
599
1018
  ...seedHex ? { seedHex } : {}
600
1019
  }
601
1020
  },
1021
+ { t: "link", ns: BRAND_NS.palette, id: paletteId, label: "book", target: proposal.bookId },
602
1022
  ...upsertSectionOps({
603
1023
  bookId: proposal.bookId,
604
1024
  kind: "palette",
@@ -606,6 +1026,8 @@ function acceptProposalOps(input) {
606
1026
  status: "approved",
607
1027
  audience: proposal.audience,
608
1028
  updatedBy: resolvedBy,
1029
+ approvalReceiptId,
1030
+ approvalActionDigest,
609
1031
  now
610
1032
  }),
611
1033
  {
@@ -653,6 +1075,8 @@ function createAssetOps(input) {
653
1075
  const contentType = assertAssetContentType(input.contentType);
654
1076
  if (typeof input.size !== "number" || !Number.isFinite(input.size) || input.size <= 0)
655
1077
  throw new BrandInputError("size must be a positive byte count");
1078
+ if (!/^sha256:[0-9a-f]{64}$/.test(input.contentDigest))
1079
+ throw new BrandInputError("contentDigest must be a SHA-256 digest");
656
1080
  const title = input.title === void 0 ? void 0 : capString(input.title, "title", 160);
657
1081
  return [
658
1082
  {
@@ -664,27 +1088,68 @@ function createAssetOps(input) {
664
1088
  bookId: input.bookId,
665
1089
  kind: input.kind,
666
1090
  path: capString(input.path, "path", 512),
667
- url: capString(input.url, "url", 1024),
1091
+ storageObjectId: capString(input.storageObjectId, "storageObjectId", 200),
1092
+ contentDigest: capString(input.contentDigest, "contentDigest", 80),
668
1093
  contentType,
669
1094
  size: input.size,
1095
+ status: "live",
1096
+ analysisRevision: 0,
670
1097
  audience: input.audience,
671
1098
  uploadedBy: input.uploadedBy,
1099
+ uploadedAuthorityRef: capString(
1100
+ input.uploadedAuthorityRef,
1101
+ "uploadedAuthorityRef",
1102
+ 200
1103
+ ),
672
1104
  createdAt: input.now,
673
1105
  ...title ? { title } : {}
674
1106
  }
675
- }
1107
+ },
1108
+ { t: "link", ns: BRAND_NS.asset, id: input.id, label: "book", target: input.bookId }
676
1109
  ];
677
1110
  }
678
- function tombstoneAssetOps(assetId, now) {
679
- return [{ t: "update", ns: BRAND_NS.asset, id: assetId, attrs: { deletedAt: now } }];
680
- }
681
- function recordAnalysisOps(assetId, analysis, now) {
1111
+ function beginAssetDeleteOps(assetId, now, deletedBy, deletedAuthorityRef) {
1112
+ return [{
1113
+ t: "update",
1114
+ ns: BRAND_NS.asset,
1115
+ id: assetId,
1116
+ attrs: {
1117
+ status: "deleting",
1118
+ deletedAt: now,
1119
+ deletedBy: capString(deletedBy, "deletedBy", 160),
1120
+ deletedAuthorityRef: capString(deletedAuthorityRef, "deletedAuthorityRef", 200)
1121
+ }
1122
+ }];
1123
+ }
1124
+ function finishAssetDeleteOps(assetId) {
1125
+ return [{
1126
+ t: "update",
1127
+ ns: BRAND_NS.asset,
1128
+ id: assetId,
1129
+ attrs: { status: "deleted" }
1130
+ }];
1131
+ }
1132
+ async function recordAnalysisOps(assetId, analysis, priorRevision, analyzedBy, analyzedAuthorityRef, now) {
1133
+ if (!Number.isSafeInteger(priorRevision) || priorRevision < 0)
1134
+ throw new BrandInputError("prior analysis revision must be a non-negative integer");
1135
+ const normalized = assertAnalysis(analysis);
682
1136
  return [
683
1137
  {
684
1138
  t: "update",
685
1139
  ns: BRAND_NS.asset,
686
1140
  id: assetId,
687
- attrs: { analysis: assertAnalysis(analysis), analyzedAt: now }
1141
+ attrs: {
1142
+ analysis: normalized,
1143
+ analysisRevision: priorRevision + 1,
1144
+ analysisDigest: await brandJsonDigest(normalized),
1145
+ analyzedBy: capString(analyzedBy, "analyzedBy", 160),
1146
+ analyzedAuthorityRef: capString(
1147
+ analyzedAuthorityRef,
1148
+ "analyzedAuthorityRef",
1149
+ 200
1150
+ ),
1151
+ analyzedAt: now
1152
+ }
688
1153
  }
689
1154
  ];
690
1155
  }
@@ -723,11 +1188,15 @@ function viewOutput(asset, bytes, contentType) {
723
1188
  }
724
1189
  function assetTools(ctx) {
725
1190
  const loadAsset = async (assetId) => {
1191
+ const book = await ctx.loadBook();
726
1192
  const res = await ctx.db.query({
727
- [BRAND_NS.asset]: { $: { where: { id: assetId, bookId: ctx.bookId } } }
1193
+ [BRAND_NS.asset]: {
1194
+ $: { where: { id: assetId, bookId: ctx.bookId } },
1195
+ book: {}
1196
+ }
728
1197
  });
729
1198
  const row = (res[BRAND_NS.asset] ?? [])[0];
730
- if (!row || row.deletedAt) throw new BrandNotFoundError(`asset ${assetId}`);
1199
+ 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}`);
731
1200
  return row;
732
1201
  };
733
1202
  const viewAsset = {
@@ -746,8 +1215,8 @@ function assetTools(ctx) {
746
1215
  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.`
747
1216
  };
748
1217
  }
749
- const url = asset.url.startsWith("http") ? asset.url : ctx.fileBaseUrl + asset.url;
750
- const fetched = await ctx.fetchBytes(url);
1218
+ const read = await ctx.authority("brand.read");
1219
+ const fetched = await ctx.readAssetContent(asset.id);
751
1220
  if (fetched.bytes.byteLength > MAX_VIEW_BYTES) {
752
1221
  return {
753
1222
  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.`
@@ -765,7 +1234,8 @@ function assetTools(ctx) {
765
1234
  };
766
1235
  const recordAnalysis = {
767
1236
  name: "record_asset_analysis",
768
- description: "Record what you observed in an asset you viewed: a description, its dominant colors as #rrggbb hex, and tags. Overwrites any prior analysis.",
1237
+ description: "Record what you observed in an asset you viewed. Uses guarded analysis revisions so concurrent agents cannot overwrite each other silently.",
1238
+ acceptsTaint: ["tool_untrusted:view_asset"],
769
1239
  inputSchema: {
770
1240
  type: "object",
771
1241
  required: ["assetId", "description", "dominantColors", "tags"],
@@ -778,12 +1248,19 @@ function assetTools(ctx) {
778
1248
  },
779
1249
  handler: ctx.guard(async (input) => {
780
1250
  const asset = await loadAsset(String(input.assetId));
1251
+ await ctx.authority("brand.edit");
781
1252
  const analysis = assertAnalysis({
782
1253
  description: input.description,
783
1254
  dominantColors: input.dominantColors,
784
1255
  tags: input.tags
785
1256
  });
786
- await ctx.db.transact(recordAnalysisOps(asset.id, analysis, ctx.now()), { mutationId: ctx.newId() });
1257
+ const updated = await ctx.recordAssetAnalysis({
1258
+ mutationId: ctx.newId(),
1259
+ assetId: asset.id,
1260
+ analysis,
1261
+ expectedAnalysisRevision: asset.analysisRevision
1262
+ });
1263
+ 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");
787
1264
  return {
788
1265
  content: `Recorded analysis for asset ${asset.id}: ${analysis.dominantColors.length} dominant color(s), ${analysis.tags.length} tag(s).`
789
1266
  };
@@ -792,81 +1269,77 @@ function assetTools(ctx) {
792
1269
  return [viewAsset, recordAnalysis];
793
1270
  }
794
1271
 
1272
+ // src/skill/source-asset.ts
1273
+ async function proposalSourceAssetId(ctx, book, value) {
1274
+ if (value === void 0) return void 0;
1275
+ const sourceAssetId = capString(value, "sourceAssetId", 200);
1276
+ const result = await ctx.db.query({
1277
+ [BRAND_NS.asset]: {
1278
+ $: {
1279
+ where: { id: sourceAssetId, bookId: book.id, status: "live" },
1280
+ limit: 2
1281
+ },
1282
+ book: { $: { limit: 2 } }
1283
+ }
1284
+ });
1285
+ const rows = result[BRAND_NS.asset] ?? [];
1286
+ const asset = rows.length === 1 ? rows[0] : void 0;
1287
+ 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(
1288
+ "sourceAssetId must be a live asset in this brand book"
1289
+ );
1290
+ return sourceAssetId;
1291
+ }
1292
+
795
1293
  // src/skill/book-tools.ts
1294
+ var PROPOSABLE = SECTION_KINDS.filter((kind) => kind !== "palette");
796
1295
  function bookTools(ctx) {
797
- const updateSection = {
1296
+ return [{
798
1297
  name: "update_section",
799
- 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.",
1298
+ description: "Propose a typography, voice, logo, or imagery section revision for human approval. This never overwrites the live approved section.",
1299
+ acceptsTaint: ["tool_untrusted:view_asset"],
800
1300
  inputSchema: {
801
1301
  type: "object",
802
- required: ["kind", "content"],
1302
+ required: ["kind", "content", "rationale"],
803
1303
  properties: {
804
- kind: { type: "string", enum: [...SECTION_KINDS] },
805
- content: { type: "object", description: "The section payload for its kind." },
806
- status: { type: "string", enum: ["draft", "approved"] }
1304
+ kind: { type: "string", enum: PROPOSABLE },
1305
+ content: { type: "object", description: "The complete proposed section payload." },
1306
+ rationale: { type: "string", description: "Why this revision should replace the live section." },
1307
+ sourceAssetId: {
1308
+ type: "string",
1309
+ description: "Optional exact live Brand asset that grounds this revision."
1310
+ }
807
1311
  }
808
1312
  },
809
1313
  handler: ctx.guard(async (input) => {
1314
+ const allowed = /* @__PURE__ */ new Set(["kind", "content", "rationale", "sourceAssetId"]);
1315
+ if (Object.keys(input).some((key) => !allowed.has(key)))
1316
+ throw new BrandInputError("section proposal has unknown fields");
810
1317
  const book = await ctx.loadBook();
811
- const kind = String(input.kind);
812
- const ops = upsertSectionOps({
813
- bookId: ctx.bookId,
1318
+ await ctx.authority("brand.edit");
1319
+ const kind = input.kind;
1320
+ if (kind === "palette" || !PROPOSABLE.includes(kind))
1321
+ throw new BrandInputError(`kind must be one of: ${PROPOSABLE.join(", ")}`);
1322
+ const rationale = capString(input.rationale, "rationale", 2e3);
1323
+ const sourceAssetId = await proposalSourceAssetId(
1324
+ ctx,
1325
+ book,
1326
+ input.sourceAssetId
1327
+ );
1328
+ const proposal = await ctx.createProposal({
1329
+ mutationId: ctx.newId(),
814
1330
  kind,
815
- content: input.content,
816
- status: input.status === void 0 ? void 0 : input.status,
817
- audience: book.memberIds,
818
- updatedBy: ctx.self.selfId,
819
- now: ctx.now()
820
- });
821
- await ctx.db.transact(ops, { mutationId: ctx.newId() });
822
- const status = input.status === void 0 ? "draft" : String(input.status);
823
- return { content: `Saved ${kind} section as ${status} (key ${sectionKey(ctx.bookId, kind)}).` };
824
- })
825
- };
826
- const compileTokens = {
827
- name: "compile_tokens",
828
- 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.",
829
- inputSchema: {
830
- type: "object",
831
- properties: { paletteId: { type: "string", description: "Defaults to the book's active palette." } }
832
- },
833
- handler: ctx.guard(async (input) => {
834
- const book = await ctx.loadBook();
835
- const paletteId = input.paletteId === void 0 ? book.activePaletteId : capString(input.paletteId, "paletteId", 128);
836
- if (!paletteId) {
837
- throw new BrandInputError("no palette to compile: pass paletteId, or get a palette proposal accepted first");
838
- }
839
- const pres = await ctx.db.query({
840
- [BRAND_NS.palette]: { $: { where: { id: paletteId, bookId: ctx.bookId } } }
841
- });
842
- const palette = (pres[BRAND_NS.palette] ?? [])[0];
843
- if (!palette) throw new BrandNotFoundError(`palette ${paletteId}`);
844
- const sres = await ctx.db.query({
845
- [BRAND_NS.section]: { $: { where: { key: sectionKey(ctx.bookId, "typography") } } }
846
- });
847
- const typography = (sres[BRAND_NS.section] ?? [])[0];
848
- const compiled = compileBrandTokens({
849
- swatches: palette.swatches,
850
- ...typography ? { typography: typography.content } : {}
1331
+ payload: {
1332
+ content: assertSectionContent(kind, input.content)
1333
+ },
1334
+ rationale,
1335
+ ...sourceAssetId ? { sourceAssetId } : {}
851
1336
  });
852
- const now = ctx.now();
853
- const snapshot = {
854
- light: compiled.light,
855
- dark: compiled.dark,
856
- warnings: compiled.warnings,
857
- compiledAt: now
1337
+ 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");
1338
+ return {
1339
+ content: `Parked ${kind} section proposal ${proposal.id}. The live section is unchanged; a human must approve this exact revision.`
858
1340
  };
859
- await ctx.db.transact(updateBookOps(ctx.bookId, { tokens: snapshot }, now), { mutationId: ctx.newId() });
860
- const count = compiled.warnings.length;
861
- const lines = [
862
- `Compiled ${Object.keys(compiled.light).length} light + ${Object.keys(compiled.dark).length} dark tokens from palette ${paletteId}; ${count} warning(s).`,
863
- ...compiled.warnings.slice(0, 3).map((w) => `- ${w.token}: ${w.message}${w.adjustedFrom ? ` (adjusted from ${w.adjustedFrom})` : ""}`)
864
- ];
865
- if (count > 3) lines.push(`\u2026 and ${count - 3} more.`);
866
- return { content: lines.join("\n") };
867
1341
  })
868
- };
869
- return [updateSection, compileTokens];
1342
+ }];
870
1343
  }
871
1344
 
872
1345
  // src/skill/palette-tools.ts
@@ -994,6 +1467,7 @@ function paletteTools(ctx) {
994
1467
  const proposePalette = {
995
1468
  name: "propose_palette",
996
1469
  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.",
1470
+ acceptsTaint: ["tool_untrusted:view_asset"],
997
1471
  inputSchema: {
998
1472
  type: "object",
999
1473
  required: ["name", "rationale"],
@@ -1002,6 +1476,10 @@ function paletteTools(ctx) {
1002
1476
  rationale: { type: "string", description: "Why these colors \u2014 cite assets, harmony, contrast." },
1003
1477
  seedHex: { type: "string" },
1004
1478
  harmony: { type: "string", enum: Object.keys(HARMONY_COMPANIONS) },
1479
+ sourceAssetId: {
1480
+ type: "string",
1481
+ description: "Optional exact asset id that grounded this palette."
1482
+ },
1005
1483
  swatches: {
1006
1484
  type: "array",
1007
1485
  items: {
@@ -1017,8 +1495,16 @@ function paletteTools(ctx) {
1017
1495
  }
1018
1496
  }
1019
1497
  },
1020
- handler: ctx.guard(async (input) => {
1498
+ handler: ctx.guard(async (input, toolCtx) => {
1021
1499
  const book = await ctx.loadBook();
1500
+ await ctx.authority("brand.edit");
1501
+ const name = capString(input.name, "name", 120);
1502
+ const rationale = capString(input.rationale, "rationale", 2e3);
1503
+ const sourceAssetId = await proposalSourceAssetId(
1504
+ ctx,
1505
+ book,
1506
+ input.sourceAssetId
1507
+ );
1022
1508
  const seedHex = input.seedHex === void 0 ? void 0 : assertHex(input.seedHex, "seedHex");
1023
1509
  let swatches;
1024
1510
  if (input.swatches !== void 0) {
@@ -1029,84 +1515,53 @@ function paletteTools(ctx) {
1029
1515
  if (input.harmony !== void 0) swatches = applyHarmony(swatches, seedHex, input.harmony);
1030
1516
  }
1031
1517
  const report = contrastReport(swatches);
1032
- const id = ctx.newId();
1033
- const ops = proposePaletteOps({
1034
- id,
1035
- bookId: ctx.bookId,
1036
- name: String(input.name),
1037
- rationale: String(input.rationale),
1038
- swatches,
1039
- seedHex,
1040
- contrastReport: report,
1041
- createdBy: ctx.self.selfId,
1042
- audience: book.memberIds,
1043
- now: ctx.now()
1518
+ const proposal = await ctx.createProposal({
1519
+ mutationId: ctx.newId(),
1520
+ kind: "palette",
1521
+ payload: {
1522
+ name,
1523
+ swatches,
1524
+ ...seedHex ? { seedHex } : {},
1525
+ contrastReport: report
1526
+ },
1527
+ rationale,
1528
+ ...sourceAssetId ? { sourceAssetId } : {}
1044
1529
  });
1045
- await ctx.db.transact(ops, { mutationId: id });
1530
+ 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");
1046
1531
  const reportText = Object.entries(report).map(([k, v]) => `${k} ${r2(v)}:1`).join(", ");
1047
1532
  return {
1048
- 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.`
1533
+ 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.`
1049
1534
  };
1050
1535
  })
1051
1536
  };
1052
- const resolveProposal = {
1053
- name: "resolve_proposal",
1054
- 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.",
1055
- inputSchema: {
1056
- type: "object",
1057
- required: ["proposalId", "resolution"],
1058
- properties: {
1059
- proposalId: { type: "string" },
1060
- resolution: { type: "string", enum: ["accepted", "rejected"] },
1061
- note: { type: "string" }
1062
- }
1063
- },
1064
- handler: ctx.guard(async (input) => {
1065
- const proposalId = String(input.proposalId);
1066
- const res = await ctx.db.query({
1067
- [BRAND_NS.proposal]: { $: { where: { id: proposalId, bookId: ctx.bookId } } }
1068
- });
1069
- const proposal = (res[BRAND_NS.proposal] ?? [])[0];
1070
- if (!proposal) throw new BrandNotFoundError(`proposal ${proposalId}`);
1071
- const note = input.note === void 0 ? void 0 : capString(input.note, "note", 1e3);
1072
- const now = ctx.now();
1073
- if (input.resolution === "accepted") {
1074
- const paletteId = ctx.newId();
1075
- const ops = acceptProposalOps({ proposal, paletteId, resolvedBy: ctx.self.selfId, now, resolutionNote: note });
1076
- await ctx.db.transact(ops, { mutationId: paletteId });
1077
- return { content: `Proposal ${proposalId} accepted \u2014 palette ${paletteId} written and set as the book's active palette.` };
1078
- }
1079
- if (input.resolution === "rejected") {
1080
- const ops = rejectProposalOps({ proposal, resolvedBy: ctx.self.selfId, now, resolutionNote: note });
1081
- await ctx.db.transact(ops, { mutationId: ctx.newId() });
1082
- return { content: `Proposal ${proposalId} rejected.` };
1083
- }
1084
- throw new BrandInputError('resolution must be "accepted" or "rejected"');
1085
- })
1086
- };
1087
- return [analyzeColor, evaluateContrast, proposePalette, resolveProposal];
1537
+ return [analyzeColor, evaluateContrast, proposePalette];
1088
1538
  }
1089
1539
 
1090
1540
  // src/skill/read-tools.ts
1091
1541
  var iso = (ms) => new Date(ms).toISOString();
1092
- function bookLines(book) {
1542
+ var shortId = (id) => id.length <= 12 ? id : `${id.slice(0, 6)}\u2026${id.slice(-4)}`;
1543
+ var principalLabel = (id, directory) => {
1544
+ const value = directory.get(id);
1545
+ return value ? `${value.displayName} [${value.kind} \xB7 ${shortId(id)}]` : `Unknown principal [${shortId(id)}]`;
1546
+ };
1547
+ function bookLines(book, directory) {
1093
1548
  const lines = [
1094
1549
  `brand book "${book.name}" (${book.slug}) \u2014 ${book.status}`,
1095
- `members: ${book.memberIds.join(", ")}`,
1550
+ `members: ${book.memberIds.map((id) => principalLabel(id, directory)).join(", ")}`,
1096
1551
  `active palette: ${book.activePaletteId ?? "(none accepted yet)"}`,
1097
1552
  book.tokens ? `tokens: compiled ${iso(book.tokens.compiledAt)} with ${book.tokens.warnings.length} warning(s)` : "tokens: (not compiled)"
1098
1553
  ];
1099
1554
  if (book.summary) lines.push(`summary: ${book.summary}`);
1100
1555
  return lines;
1101
1556
  }
1102
- function childLines(sections, palettes, proposals) {
1557
+ function childLines(sections, palettes, proposals, directory) {
1103
1558
  return [
1104
1559
  "sections:",
1105
- ...sections.length ? sections.map((s) => `- ${s.kind}: ${s.status}, updated ${iso(s.updatedAt)} by ${s.updatedBy}`) : ["- (none)"],
1560
+ ...sections.length ? sections.map((s) => `- ${s.kind}: ${s.status}, updated ${iso(s.updatedAt)} by ` + principalLabel(s.updatedBy, directory)) : ["- (none)"],
1106
1561
  "palettes:",
1107
1562
  ...palettes.length ? palettes.map((p) => `- ${p.id} "${p.name}" (${p.status}, ${p.source}, ${p.swatches.length} swatches)`) : ["- (none)"],
1108
1563
  "open proposals:",
1109
- ...proposals.length ? proposals.map((p) => `- ${p.id} (${p.kind}) by ${p.createdBy}: ${p.rationale}`) : ["- (none)"]
1564
+ ...proposals.length ? proposals.map((p) => `- ${p.id} (${p.kind}) by ${principalLabel(p.createdBy, directory)}: ${p.rationale}`) : ["- (none)"]
1110
1565
  ];
1111
1566
  }
1112
1567
  function readTools(ctx) {
@@ -1115,18 +1570,48 @@ function readTools(ctx) {
1115
1570
  description: "Read the current brand book: status, members, active palette, compiled tokens, sections, palettes, and open proposals.",
1116
1571
  inputSchema: { type: "object", properties: {} },
1117
1572
  handler: ctx.guard(async () => {
1573
+ const authorizedBook = await ctx.loadBook();
1118
1574
  const res = await ctx.db.query({
1119
- [BRAND_NS.book]: { $: { where: { id: ctx.bookId } } },
1120
- [BRAND_NS.section]: { $: { where: { bookId: ctx.bookId }, order: { updatedAt: "asc" } } },
1121
- [BRAND_NS.palette]: { $: { where: { bookId: ctx.bookId }, order: { createdAt: "asc" } } },
1122
- [BRAND_NS.proposal]: { $: { where: { bookId: ctx.bookId, status: "open" }, order: { createdAt: "asc" } } }
1575
+ [BRAND_NS.section]: {
1576
+ $: { where: { bookId: ctx.bookId }, order: { updatedAt: "asc" } },
1577
+ book: {}
1578
+ },
1579
+ [BRAND_NS.palette]: {
1580
+ $: { where: { bookId: ctx.bookId }, order: { createdAt: "asc" } },
1581
+ book: {}
1582
+ },
1583
+ [BRAND_NS.proposal]: {
1584
+ $: {
1585
+ where: { bookId: ctx.bookId, status: "open" },
1586
+ order: { createdAt: "asc" }
1587
+ },
1588
+ book: {}
1589
+ }
1123
1590
  });
1124
- const book = (res[BRAND_NS.book] ?? [])[0];
1125
- if (!book) throw new BrandNotFoundError(`brand book ${ctx.bookId}`);
1126
- const sections = res[BRAND_NS.section] ?? [];
1127
- const palettes = res[BRAND_NS.palette] ?? [];
1128
- const proposals = res[BRAND_NS.proposal] ?? [];
1129
- return { content: [...bookLines(book), ...childLines(sections, palettes, proposals)].join("\n") };
1591
+ const linked = (rows) => rows.filter((row) => row.bookId === authorizedBook.id && Array.isArray(row.book) && row.book.length === 1 && row.book[0]?.id === authorizedBook.id);
1592
+ const sections = linked(
1593
+ res[BRAND_NS.section] ?? []
1594
+ );
1595
+ const palettes = linked(
1596
+ res[BRAND_NS.palette] ?? []
1597
+ );
1598
+ const proposals = linked(
1599
+ res[BRAND_NS.proposal] ?? []
1600
+ );
1601
+ const ids = [
1602
+ ...authorizedBook.memberIds,
1603
+ ...sections.map((section) => section.updatedBy),
1604
+ ...proposals.map((proposal) => proposal.createdBy)
1605
+ ];
1606
+ const directory = new Map(
1607
+ (await ctx.resolvePrincipals(ids)).map((principal) => [principal.id, principal])
1608
+ );
1609
+ return {
1610
+ content: [
1611
+ ...bookLines(authorizedBook, directory),
1612
+ ...childLines(sections, palettes, proposals, directory)
1613
+ ].join("\n")
1614
+ };
1130
1615
  })
1131
1616
  };
1132
1617
  const listAssets = {
@@ -1139,15 +1624,21 @@ function readTools(ctx) {
1139
1624
  }
1140
1625
  },
1141
1626
  handler: ctx.guard(async (input) => {
1142
- const where = { bookId: ctx.bookId };
1627
+ await ctx.loadBook();
1628
+ const where = { bookId: ctx.bookId, status: "live" };
1143
1629
  if (input.kind !== void 0) {
1144
1630
  if (typeof input.kind !== "string" || !ASSET_KINDS.includes(input.kind)) {
1145
1631
  throw new BrandInputError(`kind must be one of: ${ASSET_KINDS.join(", ")}`);
1146
1632
  }
1147
1633
  where.kind = input.kind;
1148
1634
  }
1149
- const res = await ctx.db.query({ [BRAND_NS.asset]: { $: { where, order: { createdAt: "asc" } } } });
1150
- const rows = (res[BRAND_NS.asset] ?? []).filter((a2) => !a2.deletedAt);
1635
+ const res = await ctx.db.query({
1636
+ [BRAND_NS.asset]: {
1637
+ $: { where, order: { createdAt: "asc" } },
1638
+ book: {}
1639
+ }
1640
+ });
1641
+ 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);
1151
1642
  if (rows.length === 0) {
1152
1643
  return { content: input.kind ? `(no ${String(input.kind)} assets uploaded yet)` : "(no assets uploaded yet)" };
1153
1644
  }
@@ -1161,29 +1652,64 @@ function readTools(ctx) {
1161
1652
  }
1162
1653
 
1163
1654
  // src/skill/skill.ts
1164
- 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.";
1655
+ 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.";
1165
1656
  function brandSkill(opts) {
1166
- const deps = resolveDeps({ db: opts.db, now: opts.now, newId: opts.newId, fetchBytes: opts.fetchBytes });
1657
+ if (opts.agentDbBinding.principalId !== opts.self.selfId || !opts.agentDbBinding.credentialRef) throw new BrandForbiddenError("brand db is not bound to the acting agent");
1658
+ const deps = resolveDeps({ db: opts.db, now: opts.now, newId: opts.newId });
1659
+ const authority = (capability) => Promise.resolve(opts.authorizeCapability({
1660
+ agentId: opts.self.selfId,
1661
+ bookId: opts.bookId,
1662
+ capability
1663
+ }) ?? null).then((result) => {
1664
+ if (!result || result.capability !== capability || !result.authorityRef)
1665
+ throw new BrandForbiddenError(`agent lacks ${capability} for brand book ${opts.bookId}`);
1666
+ return result;
1667
+ });
1167
1668
  const ctx = {
1168
1669
  db: deps.db,
1169
1670
  bookId: opts.bookId,
1170
1671
  self: opts.self,
1171
- fileBaseUrl: opts.fileBaseUrl,
1172
- fetchBytes: deps.fetchBytes,
1173
1672
  visionInToolResults: opts.visionInToolResults !== false,
1174
1673
  now: deps.now,
1175
1674
  newId: deps.newId,
1675
+ agentJobId: opts.agentJobId,
1676
+ createProposal: (input) => opts.agentBridge.createProposal({
1677
+ jobId: opts.agentJobId,
1678
+ bookId: opts.bookId,
1679
+ ...input
1680
+ }),
1681
+ recordAssetAnalysis: (input) => opts.agentBridge.recordAssetAnalysis({
1682
+ jobId: opts.agentJobId,
1683
+ bookId: opts.bookId,
1684
+ ...input
1685
+ }),
1686
+ readAssetContent: (assetId) => opts.agentBridge.readAssetContent({
1687
+ jobId: opts.agentJobId,
1688
+ bookId: opts.bookId,
1689
+ assetId
1690
+ }),
1691
+ resolvePrincipals: (principalIds) => {
1692
+ const ids = [...new Set(principalIds)].slice(0, 100);
1693
+ return opts.resolvePrincipals({
1694
+ requesterAgentId: opts.self.selfId,
1695
+ bookId: opts.bookId,
1696
+ principalIds: ids
1697
+ });
1698
+ },
1699
+ authority,
1176
1700
  loadBook: async () => {
1701
+ await authority("brand.read");
1177
1702
  const res = await deps.db.query({ [BRAND_NS.book]: { $: { where: { id: opts.bookId } } } });
1178
1703
  const row = (res[BRAND_NS.book] ?? [])[0];
1179
- if (!row) throw new BrandNotFoundError(`brand book ${opts.bookId}`);
1704
+ if (!row || !row.memberIds.includes(opts.self.selfId))
1705
+ throw new BrandNotFoundError(`brand book ${opts.bookId}`);
1180
1706
  return row;
1181
1707
  },
1182
1708
  guard: (handler) => async (input, toolCtx) => {
1183
1709
  try {
1184
1710
  return await handler(input, toolCtx);
1185
1711
  } catch (error) {
1186
- if (error instanceof BrandInputError || error instanceof BrandNotFoundError) {
1712
+ if (error instanceof BrandInputError || error instanceof BrandNotFoundError || error instanceof BrandForbiddenError) {
1187
1713
  return { content: error.message, isError: true };
1188
1714
  }
1189
1715
  throw error;
@@ -1198,7 +1724,7 @@ function brandSkill(opts) {
1198
1724
  }
1199
1725
 
1200
1726
  // src/skill/persona.ts
1201
- 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.";
1727
+ 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.";
1202
1728
  function createBrandPersona(opts) {
1203
1729
  return {
1204
1730
  name: "brand-director",
@@ -1214,7 +1740,35 @@ function supportsBrandVision(spec) {
1214
1740
  return !!(caps?.toolResultBlocks && caps.imageIn && caps.documentIn);
1215
1741
  }
1216
1742
 
1743
+ // src/routes/asset-query.ts
1744
+ async function linkedAsset(ctx, book, assetId, allowDeleting = false) {
1745
+ const result = await ctx.db.query({
1746
+ [BRAND_NS.asset]: {
1747
+ $: { where: { id: assetId, bookId: book.id } },
1748
+ book: {}
1749
+ }
1750
+ });
1751
+ const row = (result[BRAND_NS.asset] ?? [])[0];
1752
+ const links = row?.book;
1753
+ 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}`);
1754
+ return row;
1755
+ }
1756
+
1217
1757
  // src/routes/http.ts
1758
+ async function requireRouteAuthority(ctx, req, capability, effect, bookId) {
1759
+ if (ctx.actor.kind !== "human")
1760
+ throw new BrandForbiddenError("this brand effect requires a directly authenticated human");
1761
+ const result = await ctx.authorizeCapability({
1762
+ req,
1763
+ actor: ctx.actor,
1764
+ appId: ctx.appId,
1765
+ ...bookId ? { bookId } : {},
1766
+ capability,
1767
+ effect
1768
+ });
1769
+ 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`);
1770
+ return result;
1771
+ }
1218
1772
  var json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
1219
1773
  status,
1220
1774
  headers: { "content-type": "application/json", ...headers }
@@ -1222,7 +1776,12 @@ var json = (body, status = 200, headers = {}) => new Response(JSON.stringify(bod
1222
1776
  var errorResponse = (error) => {
1223
1777
  if (error instanceof BrandInputError)
1224
1778
  return json({ error: error.message, ...error.fields ? { fields: error.fields } : {} }, 400);
1779
+ if (error instanceof BrandForbiddenError) return json({ error: error.message }, 403);
1225
1780
  if (error instanceof BrandNotFoundError) return json({ error: error.message }, 404);
1781
+ if (error instanceof BrandGoneError) return json({ error: error.message }, 410);
1782
+ if (error instanceof BrandReviewStateChangedError)
1783
+ return json({ error: error.message, code: error.code }, 409);
1784
+ if (error instanceof BrandConflictError) return json({ error: error.message }, 409);
1226
1785
  return json({ error: "internal error" }, 500);
1227
1786
  };
1228
1787
  var methodNotAllowed = () => json({ error: "method not allowed" }, 405);
@@ -1271,86 +1830,217 @@ async function loadMemberBook(db, bookId, actorId) {
1271
1830
  return book;
1272
1831
  }
1273
1832
 
1274
- // src/routes/assets.ts
1833
+ // src/routes/asset-upload.ts
1275
1834
  async function readForm(req) {
1276
1835
  try {
1277
1836
  return await req.formData();
1278
1837
  } catch {
1279
- throw new BrandInputError('expected a multipart/form-data body with a "file" field');
1838
+ throw new BrandInputError(
1839
+ 'expected multipart/form-data with a "file" field'
1840
+ );
1280
1841
  }
1281
1842
  }
1843
+ async function contentDigest(file) {
1844
+ const hash = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
1845
+ return `sha256:${[...new Uint8Array(hash)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
1846
+ }
1282
1847
  async function uploadAsset(ctx, req, bookId) {
1283
1848
  const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
1284
1849
  const form = await readForm(req);
1285
1850
  const file = form.get("file");
1286
- if (!(file instanceof File)) throw new BrandInputError('"file" must be an uploaded file field');
1851
+ if (!(file instanceof File))
1852
+ throw new BrandInputError('"file" must be an uploaded file field');
1287
1853
  let contentType;
1288
1854
  try {
1289
1855
  contentType = assertAssetContentType(file.type);
1290
1856
  } catch (error) {
1291
- if (error instanceof BrandInputError) return json({ error: error.message }, 415);
1857
+ if (error instanceof BrandInputError)
1858
+ return json({ error: error.message }, 415);
1292
1859
  throw error;
1293
1860
  }
1294
1861
  if (file.size > ctx.maxUploadBytes)
1295
- return json(
1296
- { error: `file is ${file.size} bytes; the upload limit is ${ctx.maxUploadBytes}` },
1297
- 413
1298
- );
1862
+ return json({ error: `file exceeds ${ctx.maxUploadBytes} bytes` }, 413);
1299
1863
  const kindRaw = form.get("kind");
1300
- const kind = typeof kindRaw === "string" && kindRaw !== "" ? kindRaw : "other";
1864
+ const kind = typeof kindRaw === "string" && kindRaw ? kindRaw : "other";
1301
1865
  if (!ASSET_KINDS.includes(kind))
1302
1866
  throw new BrandInputError(`kind must be one of: ${ASSET_KINDS.join(", ")}`);
1303
1867
  const titleRaw = form.get("title");
1304
- const title = typeof titleRaw === "string" && titleRaw !== "" ? capString(titleRaw, "title", 160) : void 0;
1868
+ const title = typeof titleRaw === "string" && titleRaw ? capString(titleRaw, "title", 160) : void 0;
1869
+ const fileName = safeFileName(file.name);
1305
1870
  const id = ctx.newId();
1306
- const path = `brand/${book.id}/assets/${id}/${safeFileName(file.name)}`;
1307
- const record = await ctx.db.storage.upload(path, file, contentType);
1308
- await ctx.db.transact(
1309
- createAssetOps({
1871
+ const path = `brand/${book.id}/assets/${id}/${fileName}`;
1872
+ const authority = await requireRouteAuthority(
1873
+ ctx,
1874
+ req,
1875
+ "brand.edit",
1876
+ "internal",
1877
+ book.id
1878
+ );
1879
+ const record6 = await ctx.db.storage.upload(
1880
+ path,
1881
+ file,
1882
+ contentType,
1883
+ { private: true }
1884
+ );
1885
+ if (record6.path !== path) {
1886
+ await ctx.db.storage.delete(record6.path);
1887
+ throw new BrandConflictError(
1888
+ "private storage returned an unexpected asset path"
1889
+ );
1890
+ }
1891
+ try {
1892
+ const current = await requireRouteAuthority(
1893
+ ctx,
1894
+ req,
1895
+ "brand.edit",
1896
+ "internal",
1897
+ book.id
1898
+ );
1899
+ if (current.authorityRef !== authority.authorityRef)
1900
+ throw new BrandConflictError("brand edit authority changed during upload");
1901
+ await ctx.db.transact(createAssetOps({
1310
1902
  id,
1311
1903
  bookId: book.id,
1312
1904
  kind,
1313
- path: record.path,
1314
- url: record.url,
1905
+ path: record6.path,
1906
+ storageObjectId: record6.id,
1907
+ contentDigest: await contentDigest(file),
1315
1908
  contentType,
1316
- size: record.size,
1909
+ size: record6.size,
1317
1910
  uploadedBy: ctx.actor.id,
1911
+ uploadedAuthorityRef: authority.authorityRef,
1318
1912
  audience: book.memberIds,
1319
1913
  title,
1320
1914
  now: ctx.now()
1321
- }),
1322
- { mutationId: id }
1323
- );
1324
- const res = await ctx.db.query({ [BRAND_NS.asset]: { $: { where: { id } } } });
1325
- return json((res[BRAND_NS.asset] ?? [])[0], 201);
1326
- }
1327
- async function handleAssetsRoot(ctx, req, bookId) {
1328
- if (req.method === "GET") {
1329
- const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
1330
- const res = await ctx.db.query({
1331
- [BRAND_NS.asset]: {
1332
- // `deletedAt: null` matches ONLY rows without the attr — odla-db's
1333
- // "absence is a missing triple" semantics — i.e. not tombstoned.
1334
- $: { where: { bookId: book.id, deletedAt: null }, order: { createdAt: "asc" } }
1335
- }
1915
+ }), {
1916
+ mutationId: `brand:asset-upload:v1:${id}`,
1917
+ guards: [
1918
+ { ns: BRAND_NS.asset, id, exists: false },
1919
+ {
1920
+ ns: BRAND_NS.book,
1921
+ id: book.id,
1922
+ exists: true,
1923
+ equals: {
1924
+ version: book.version,
1925
+ activationRevision: book.activationRevision,
1926
+ memberIds: book.memberIds
1927
+ }
1928
+ }
1929
+ ],
1930
+ asUser: ctx.actor.id,
1931
+ ...ctx.actor.email ? { asEmail: ctx.actor.email } : {},
1932
+ asPrincipalKind: "human"
1336
1933
  });
1337
- return json({ assets: res[BRAND_NS.asset] ?? [] });
1934
+ } catch (error) {
1935
+ await ctx.db.storage.delete(record6.path);
1936
+ throw error;
1338
1937
  }
1938
+ return json(await linkedAsset(ctx, book, id), 201);
1939
+ }
1940
+
1941
+ // src/routes/assets.ts
1942
+ async function handleAssetsRoot(ctx, req, bookId) {
1339
1943
  if (req.method === "POST") return uploadAsset(ctx, req, bookId);
1340
- return methodNotAllowed();
1944
+ if (req.method !== "GET") return methodNotAllowed();
1945
+ const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
1946
+ const result = await ctx.db.query({
1947
+ [BRAND_NS.asset]: {
1948
+ $: { where: { bookId: book.id, status: "live" }, order: { createdAt: "asc" } },
1949
+ book: {}
1950
+ }
1951
+ });
1952
+ const assets = (result[BRAND_NS.asset] ?? []).filter((value) => {
1953
+ const row = value;
1954
+ return row.bookId === book.id && Array.isArray(row.book) && row.book.length === 1 && row.book[0]?.id === book.id;
1955
+ });
1956
+ return json({ assets });
1957
+ }
1958
+ async function handleAssetContent(ctx, req, bookId, assetId) {
1959
+ if (req.method !== "GET") return methodNotAllowed();
1960
+ const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
1961
+ const asset = await linkedAsset(ctx, book, assetId);
1962
+ const expiresInSeconds = 300;
1963
+ const url = await ctx.db.storage.sign(asset.path, expiresInSeconds);
1964
+ return json(
1965
+ { assetId, url, expiresInSeconds },
1966
+ 200,
1967
+ { "cache-control": "private, no-store", "x-content-type-options": "nosniff" }
1968
+ );
1341
1969
  }
1342
1970
  async function handleAssetItem(ctx, req, bookId, assetId) {
1343
1971
  if (req.method !== "DELETE") return methodNotAllowed();
1344
1972
  const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
1345
- const res = await ctx.db.query({
1346
- [BRAND_NS.asset]: { $: { where: { id: assetId, bookId: book.id } } }
1347
- });
1348
- const asset = (res[BRAND_NS.asset] ?? [])[0];
1349
- if (!asset || asset.deletedAt != null) throw new BrandNotFoundError(`asset ${assetId}`);
1973
+ let asset = await linkedAsset(ctx, book, assetId, true);
1974
+ const authority = await requireRouteAuthority(
1975
+ ctx,
1976
+ req,
1977
+ "brand.edit",
1978
+ "destructive",
1979
+ book.id
1980
+ );
1981
+ const mutationBase = `brand:asset-delete:v1:${book.id}:${asset.id}`;
1982
+ if (asset.status === "live") {
1983
+ const deletedAt = ctx.now();
1984
+ try {
1985
+ await ctx.db.transact(beginAssetDeleteOps(
1986
+ asset.id,
1987
+ deletedAt,
1988
+ ctx.actor.id,
1989
+ authority.authorityRef
1990
+ ), {
1991
+ mutationId: `${mutationBase}:begin`,
1992
+ guards: [
1993
+ {
1994
+ ns: BRAND_NS.asset,
1995
+ id: asset.id,
1996
+ exists: true,
1997
+ equals: {
1998
+ bookId: book.id,
1999
+ path: asset.path,
2000
+ storageObjectId: asset.storageObjectId,
2001
+ contentDigest: asset.contentDigest,
2002
+ status: "live"
2003
+ }
2004
+ },
2005
+ {
2006
+ ns: BRAND_NS.book,
2007
+ id: book.id,
2008
+ exists: true,
2009
+ equals: { version: book.version, memberIds: book.memberIds }
2010
+ }
2011
+ ],
2012
+ asUser: ctx.actor.id,
2013
+ ...ctx.actor.email ? { asEmail: ctx.actor.email } : {},
2014
+ asPrincipalKind: "human"
2015
+ });
2016
+ } catch (error) {
2017
+ if (typeof error === "object" && error !== null && error.code === "transact_guard_failed") throw new BrandConflictError("asset changed before deletion");
2018
+ throw error;
2019
+ }
2020
+ asset = { ...asset, status: "deleting", deletedAt };
2021
+ }
1350
2022
  await ctx.db.storage.delete(asset.path);
1351
- const deletedAt = ctx.now();
1352
- await ctx.db.transact(tombstoneAssetOps(asset.id, deletedAt));
1353
- return json({ id: asset.id, deletedAt });
2023
+ await ctx.db.transact(finishAssetDeleteOps(asset.id), {
2024
+ mutationId: `${mutationBase}:finish`,
2025
+ guards: [{
2026
+ ns: BRAND_NS.asset,
2027
+ id: asset.id,
2028
+ exists: true,
2029
+ equals: {
2030
+ bookId: book.id,
2031
+ path: asset.path,
2032
+ storageObjectId: asset.storageObjectId,
2033
+ contentDigest: asset.contentDigest,
2034
+ status: "deleting",
2035
+ deletedBy: asset.deletedBy ?? ctx.actor.id,
2036
+ deletedAuthorityRef: asset.deletedAuthorityRef ?? authority.authorityRef
2037
+ }
2038
+ }],
2039
+ asUser: ctx.actor.id,
2040
+ ...ctx.actor.email ? { asEmail: ctx.actor.email } : {},
2041
+ asPrincipalKind: "human"
2042
+ });
2043
+ return json({ id: asset.id, status: "deleted", deletedAt: asset.deletedAt });
1354
2044
  }
1355
2045
 
1356
2046
  // src/routes/books.ts
@@ -1363,73 +2053,1036 @@ async function handleBooksRoot(ctx, req) {
1363
2053
  return json({ books });
1364
2054
  }
1365
2055
  if (req.method === "POST") {
2056
+ if (ctx.actor.kind !== "human")
2057
+ throw new BrandForbiddenError("only a human can create a brand book");
1366
2058
  const body = await readJson(req);
1367
2059
  const id = ctx.newId();
2060
+ const authority = await requireRouteAuthority(
2061
+ ctx,
2062
+ req,
2063
+ "brand.edit",
2064
+ "internal"
2065
+ );
1368
2066
  const ops = createBookOps({
1369
2067
  id,
1370
2068
  slug: str(body, "slug"),
1371
2069
  name: str(body, "name"),
1372
2070
  ownerId: ctx.actor.id,
2071
+ createdAuthorityRef: authority.authorityRef,
1373
2072
  memberIds: optStrArray(body, "memberIds") ?? [],
1374
2073
  channelId: optStr(body, "channelId"),
1375
2074
  now: ctx.now()
1376
2075
  });
1377
- await ctx.db.transact(ops, { mutationId: id });
2076
+ await ctx.db.transact(ops, {
2077
+ mutationId: id,
2078
+ guards: [{ ns: BRAND_NS.book, id, exists: false }],
2079
+ asUser: ctx.actor.id,
2080
+ ...ctx.actor.email ? { asEmail: ctx.actor.email } : {},
2081
+ asPrincipalKind: "human"
2082
+ });
1378
2083
  return json(await loadBook(ctx.db, id), 201);
1379
2084
  }
1380
2085
  return methodNotAllowed();
1381
2086
  }
2087
+ var sameStrings = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);
2088
+ var guardFailure = (error) => typeof error === "object" && error !== null && error.code === "transact_guard_failed";
2089
+ async function handleBookMembers(ctx, req, bookId) {
2090
+ if (req.method !== "PATCH") return methodNotAllowed();
2091
+ const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
2092
+ if (ctx.actor.kind !== "human")
2093
+ throw new BrandForbiddenError("only a human app owner can change members");
2094
+ const body = await readJson(req);
2095
+ const rawMembers = optStrArray(body, "memberIds");
2096
+ const rawExpected = optStrArray(body, "expectedMemberIds");
2097
+ if (!rawMembers || !rawExpected)
2098
+ throw new BrandInputError('"memberIds" and "expectedMemberIds" are required string arrays');
2099
+ const memberIds = Array.from(/* @__PURE__ */ new Set([
2100
+ book.ownerId,
2101
+ ...rawMembers.map((id, index) => capString(id, `memberIds[${index}]`, 160))
2102
+ ]));
2103
+ if (memberIds.length > 100)
2104
+ throw new BrandInputError("a brand book supports at most 100 members");
2105
+ const expectedMemberIds = rawExpected.map((id, index) => capString(id, `expectedMemberIds[${index}]`, 160));
2106
+ if (!sameStrings(book.memberIds, expectedMemberIds) && !sameStrings(book.memberIds, memberIds))
2107
+ throw new BrandConflictError("brand book membership changed since review");
2108
+ const mutationId = capString(str(body, "mutationId"), "mutationId", 200);
2109
+ const mutationKey = `brand:book-members:v1:${bookId}:${mutationId}`;
2110
+ const authority = await requireRouteAuthority(
2111
+ ctx,
2112
+ req,
2113
+ "app.manage",
2114
+ "internal",
2115
+ bookId
2116
+ );
2117
+ try {
2118
+ const tx = await ctx.db.transact(
2119
+ [{
2120
+ t: "update",
2121
+ ns: BRAND_NS.book,
2122
+ id: bookId,
2123
+ attrs: {
2124
+ memberIds,
2125
+ version: book.version + 1,
2126
+ updatedAt: ctx.now(),
2127
+ rosterAuthorityRef: authority.authorityRef
2128
+ }
2129
+ }],
2130
+ {
2131
+ mutationId: mutationKey,
2132
+ guards: [{
2133
+ ns: BRAND_NS.book,
2134
+ id: bookId,
2135
+ exists: true,
2136
+ equals: { memberIds: expectedMemberIds, version: book.version }
2137
+ }],
2138
+ asUser: ctx.actor.id,
2139
+ ...ctx.actor.email ? { asEmail: ctx.actor.email } : {},
2140
+ asPrincipalKind: "human"
2141
+ }
2142
+ );
2143
+ return json({ bookId, memberIds, duplicate: tx.duplicate === true });
2144
+ } catch (error) {
2145
+ if (guardFailure(error))
2146
+ throw new BrandConflictError("brand book membership changed since review");
2147
+ throw error;
2148
+ }
2149
+ }
1382
2150
  async function handleBookItem(ctx, req, bookId) {
1383
2151
  if (req.method !== "GET") return methodNotAllowed();
1384
2152
  return json(await loadMemberBook(ctx.db, bookId, ctx.actor.id));
1385
2153
  }
1386
2154
 
2155
+ // src/routes/proposal-input.ts
2156
+ var SNAPSHOT_KEYS = [
2157
+ "audience",
2158
+ "bookId",
2159
+ "createdAt",
2160
+ "createdAuthorityRef",
2161
+ "createdBy",
2162
+ "id",
2163
+ "kind",
2164
+ "payload",
2165
+ "provenance",
2166
+ "rationale",
2167
+ "reviewDigest",
2168
+ "status"
2169
+ ];
2170
+ var record3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2171
+ function parseProposalResolutionRequest(body, bookId, proposalId) {
2172
+ const allowed = /* @__PURE__ */ new Set([
2173
+ "mutationId",
2174
+ "resolution",
2175
+ "reviewedProposal",
2176
+ "note"
2177
+ ]);
2178
+ if (Object.keys(body).some((key) => !allowed.has(key)))
2179
+ throw new BrandInputError("proposal resolution body has unknown fields");
2180
+ const mutationId = capString(str(body, "mutationId"), "mutationId", 200);
2181
+ const rawResolution = str(body, "resolution");
2182
+ if (rawResolution !== "accepted" && rawResolution !== "rejected")
2183
+ throw new BrandInputError('"resolution" must be "accepted" or "rejected"');
2184
+ const rawNote = optStr(body, "note");
2185
+ const resolutionNote = rawNote?.trim() ? capString(rawNote, "note", 1e3) : void 0;
2186
+ return {
2187
+ mutationId,
2188
+ resolution: rawResolution,
2189
+ reviewed: parseReviewedProposal(body.reviewedProposal, bookId, proposalId),
2190
+ ...resolutionNote ? { resolutionNote } : {}
2191
+ };
2192
+ }
2193
+ function proposalReviewSnapshot(proposal) {
2194
+ if (proposal.status !== "open")
2195
+ throw new BrandConflictError(`proposal ${proposal.id} is ${proposal.status}, not open`);
2196
+ return {
2197
+ id: proposal.id,
2198
+ bookId: proposal.bookId,
2199
+ kind: proposal.kind,
2200
+ status: "open",
2201
+ payload: proposal.payload,
2202
+ rationale: proposal.rationale,
2203
+ provenance: proposal.provenance,
2204
+ reviewDigest: proposal.reviewDigest,
2205
+ audience: proposal.audience,
2206
+ createdBy: proposal.createdBy,
2207
+ createdAuthorityRef: proposal.createdAuthorityRef,
2208
+ createdAt: proposal.createdAt
2209
+ };
2210
+ }
2211
+ function parseReviewedProposal(value, bookId, proposalId) {
2212
+ if (!isBoundedBrandJson(value, { maxDepth: 12, maxNodes: 1500, maxBytes: 32 * 1024 }))
2213
+ throw new BrandInputError('"reviewedProposal" is too deeply nested, complex, or large');
2214
+ if (!record3(value)) throw new BrandInputError('"reviewedProposal" must be an object');
2215
+ const keys = Object.keys(value).sort();
2216
+ if (keys.length !== SNAPSHOT_KEYS.length || !SNAPSHOT_KEYS.every((key, index) => key === keys[index])) throw new BrandInputError('"reviewedProposal" has an invalid shape');
2217
+ if (value.id !== proposalId || value.bookId !== bookId || value.status !== "open")
2218
+ throw new BrandInputError('"reviewedProposal" must identify this open proposal');
2219
+ 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');
2220
+ 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(
2221
+ value.provenance.sourceAsset.analysisDigest
2222
+ )) || 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');
2223
+ return value;
2224
+ }
2225
+ var proposalGuardEquals = (snapshot) => ({ ...snapshot });
2226
+
2227
+ // src/routes/proposal-effects.ts
2228
+ async function tokenSnapshot(swatches, typography, input, paletteId, paletteReceiptId, paletteActionDigest, typographyReceiptId, typographyActionDigest) {
2229
+ const compiled = compileBrandTokens({ swatches, ...typography ? { typography } : {} });
2230
+ const sources = {
2231
+ paletteId,
2232
+ paletteReceiptId,
2233
+ paletteActionDigest,
2234
+ typographyReceiptId: typographyReceiptId ?? null,
2235
+ typographyActionDigest: typographyActionDigest ?? null
2236
+ };
2237
+ return {
2238
+ light: compiled.light,
2239
+ dark: compiled.dark,
2240
+ warnings: compiled.warnings,
2241
+ compiledAt: input.now,
2242
+ paletteId,
2243
+ paletteReceiptId,
2244
+ paletteActionDigest,
2245
+ ...typographyReceiptId ? { typographyReceiptId } : {},
2246
+ ...typographyActionDigest ? { typographyActionDigest } : {},
2247
+ sourceDigest: await brandJsonDigest(sources)
2248
+ };
2249
+ }
2250
+ function stampProposal(ops, input) {
2251
+ const op = ops.find(
2252
+ (candidate) => candidate.t === "update" && candidate.ns === BRAND_NS.proposal && candidate.id === input.proposal.id
2253
+ );
2254
+ if (!op || op.t !== "update") throw new Error("proposal resolution op missing");
2255
+ Object.assign(op.attrs, {
2256
+ approvedBy: input.actorId,
2257
+ appliedBy: input.actorId,
2258
+ resolutionReceiptId: input.receiptId,
2259
+ resolutionActionDigest: input.actionDigest
2260
+ });
2261
+ }
2262
+ async function proposalDecisionOps(input) {
2263
+ const common = {
2264
+ proposal: input.proposal,
2265
+ resolvedBy: input.actorId,
2266
+ now: input.now,
2267
+ resolutionNote: input.resolutionNote
2268
+ };
2269
+ if (input.resolution === "rejected") {
2270
+ const ops2 = rejectProposalOps(common);
2271
+ stampProposal(ops2, input);
2272
+ return ops2;
2273
+ }
2274
+ let ops;
2275
+ if (input.proposal.kind === "palette") {
2276
+ if (!input.paletteId) throw new Error("accepted palette id missing");
2277
+ ops = acceptProposalOps({
2278
+ ...common,
2279
+ paletteId: input.paletteId,
2280
+ approvalReceiptId: input.receiptId,
2281
+ approvalActionDigest: input.actionDigest
2282
+ });
2283
+ const swatches = assertSwatches(input.proposal.payload.swatches);
2284
+ const tokens = await tokenSnapshot(
2285
+ swatches,
2286
+ input.approvedTypography?.content,
2287
+ input,
2288
+ input.paletteId,
2289
+ input.receiptId,
2290
+ input.actionDigest,
2291
+ input.approvedTypography?.approvalReceiptId,
2292
+ input.approvedTypography?.approvalActionDigest
2293
+ );
2294
+ const bookOp = ops.find(
2295
+ (op) => op.t === "update" && op.ns === BRAND_NS.book && op.id === input.book.id
2296
+ );
2297
+ if (!bookOp || bookOp.t !== "update") throw new Error("book activation op missing");
2298
+ Object.assign(bookOp.attrs, {
2299
+ version: input.book.version + 1,
2300
+ activationRevision: input.book.activationRevision + 1,
2301
+ tokens
2302
+ });
2303
+ if (input.priorPalette && input.priorPalette.id !== input.paletteId) {
2304
+ ops.unshift({
2305
+ t: "update",
2306
+ ns: BRAND_NS.palette,
2307
+ id: input.priorPalette.id,
2308
+ attrs: { status: "archived", updatedAt: input.now }
2309
+ });
2310
+ }
2311
+ } else {
2312
+ const content = input.proposal.payload.content;
2313
+ if (typeof content !== "object" || content === null || Array.isArray(content))
2314
+ throw new BrandInputError("proposal payload.content must be an object");
2315
+ ops = [
2316
+ ...upsertSectionOps({
2317
+ bookId: input.book.id,
2318
+ kind: input.proposal.kind,
2319
+ content,
2320
+ status: "approved",
2321
+ audience: input.book.memberIds,
2322
+ updatedBy: input.actorId,
2323
+ approvalReceiptId: input.receiptId,
2324
+ approvalActionDigest: input.actionDigest,
2325
+ now: input.now
2326
+ }),
2327
+ {
2328
+ t: "update",
2329
+ ns: BRAND_NS.book,
2330
+ id: input.book.id,
2331
+ attrs: {
2332
+ activationRevision: input.book.activationRevision + 1,
2333
+ version: input.book.version + 1,
2334
+ updatedAt: input.now
2335
+ }
2336
+ },
2337
+ {
2338
+ t: "update",
2339
+ ns: BRAND_NS.proposal,
2340
+ id: input.proposal.id,
2341
+ attrs: {
2342
+ status: "accepted",
2343
+ resolvedBy: input.actorId,
2344
+ resolvedAt: input.now,
2345
+ ...input.resolutionNote ? { resolutionNote: input.resolutionNote } : {}
2346
+ }
2347
+ }
2348
+ ];
2349
+ if (input.proposal.kind === "typography" && input.priorPalette) {
2350
+ const bookOp = ops.find(
2351
+ (op) => op.t === "update" && op.ns === BRAND_NS.book && op.id === input.book.id
2352
+ );
2353
+ if (bookOp?.t === "update") {
2354
+ if (!input.priorPalette.approvalReceiptId || !input.priorPalette.approvalActionDigest)
2355
+ throw new BrandInputError("active palette is missing approval provenance");
2356
+ bookOp.attrs.tokens = await tokenSnapshot(
2357
+ input.priorPalette.swatches,
2358
+ content,
2359
+ input,
2360
+ input.priorPalette.id,
2361
+ input.priorPalette.approvalReceiptId,
2362
+ input.priorPalette.approvalActionDigest,
2363
+ input.receiptId,
2364
+ input.actionDigest
2365
+ );
2366
+ }
2367
+ }
2368
+ }
2369
+ stampProposal(ops, input);
2370
+ return ops;
2371
+ }
2372
+
2373
+ // src/routes/proposal-resolution-receipts.ts
2374
+ async function proposalResolutionMutationKey(bookId, proposalId, mutationId) {
2375
+ const digest = await brandJsonDigest({
2376
+ version: 3,
2377
+ bookId,
2378
+ proposalId,
2379
+ mutationId
2380
+ });
2381
+ return `brand:proposal-resolution:v3:${digest.slice("sha256:".length)}`;
2382
+ }
2383
+ async function proposalReceiptByMutation(ctx, mutationKey) {
2384
+ const result = await ctx.db.query({
2385
+ [BRAND_NS.approvalReceipt]: {
2386
+ $: { where: { mutationKey } },
2387
+ book: {}
2388
+ }
2389
+ });
2390
+ const row = (result[BRAND_NS.approvalReceipt] ?? [])[0];
2391
+ if (!row || !Array.isArray(row.book) || row.book.length !== 1 || row.book[0]?.id !== row.bookId) return void 0;
2392
+ const { book: _book, ...receipt2 } = row;
2393
+ return receipt2;
2394
+ }
2395
+ async function consumeProposalAuthority(ctx, req, bookId, proposalId, actionDigest, mutationKey) {
2396
+ const authority = await ctx.consumeHumanExact({
2397
+ req,
2398
+ actor: ctx.actor,
2399
+ appId: ctx.appId,
2400
+ capability: "brand.proposal.resolve",
2401
+ projectCapability: "brand.approve",
2402
+ effect: "internal",
2403
+ resource: { bookId, proposalId },
2404
+ actionDigest,
2405
+ consumptionIdempotencyKey: mutationKey
2406
+ });
2407
+ const resourceDigest = await brandJsonDigest({ bookId, proposalId });
2408
+ if (!isBrandHumanAuthorityConsumption(authority) || authority.actorPrincipalId !== ctx.actor.id || authority.appId !== ctx.appId || authority.actionDigest !== actionDigest || authority.resourceDigest !== resourceDigest || authority.consumptionIdempotencyKey !== mutationKey) throw new BrandForbiddenError(
2409
+ "a verified human-exact brand approval is required"
2410
+ );
2411
+ return authority;
2412
+ }
2413
+ function sameProposalResolutionRequest(receipt2, reviewed, resolution, note, paletteId) {
2414
+ return receipt2.resolution === resolution && (receipt2.resolutionNote ?? null) === (note ?? null) && (receipt2.paletteId ?? null) === (paletteId ?? null) && canonicalBrandJson(receipt2.reviewedProposal) === canonicalBrandJson(reviewed);
2415
+ }
2416
+
2417
+ // src/routes/proposal-dependencies.ts
2418
+ async function receipt(ctx, id, actionDigest, expected) {
2419
+ const result = await ctx.db.query({
2420
+ [BRAND_NS.approvalReceipt]: {
2421
+ $: { where: { id } },
2422
+ book: {}
2423
+ }
2424
+ });
2425
+ const row = (result[BRAND_NS.approvalReceipt] ?? [])[0];
2426
+ 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`);
2427
+ const { book: _book, ...unhydrated } = row;
2428
+ if (!await verifyBrandApprovalReceipt(unhydrated))
2429
+ throw new BrandConflictError(`approval receipt ${id} is invalid`);
2430
+ return unhydrated;
2431
+ }
2432
+ async function activePalette(ctx, book) {
2433
+ if (!book.activePaletteId) return {};
2434
+ const result = await ctx.db.query({
2435
+ [BRAND_NS.palette]: {
2436
+ $: { where: { id: book.activePaletteId } },
2437
+ book: {}
2438
+ }
2439
+ });
2440
+ const palette = (result[BRAND_NS.palette] ?? [])[0];
2441
+ 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(
2442
+ "active palette is missing valid approval provenance"
2443
+ );
2444
+ const approval = await receipt(
2445
+ ctx,
2446
+ palette.approvalReceiptId,
2447
+ palette.approvalActionDigest,
2448
+ {
2449
+ bookId: book.id,
2450
+ kind: "palette",
2451
+ paletteId: palette.id,
2452
+ proposalId: palette.proposalId
2453
+ }
2454
+ );
2455
+ let expectedName;
2456
+ let expectedSwatches;
2457
+ let expectedSeed;
2458
+ try {
2459
+ expectedName = capString(
2460
+ approval.reviewedProposal.payload.name,
2461
+ "reviewedProposal.payload.name",
2462
+ 120
2463
+ );
2464
+ expectedSwatches = assertSwatches(
2465
+ approval.reviewedProposal.payload.swatches
2466
+ );
2467
+ expectedSeed = approval.reviewedProposal.payload.seedHex === void 0 ? void 0 : assertHex(
2468
+ approval.reviewedProposal.payload.seedHex,
2469
+ "reviewedProposal.payload.seedHex"
2470
+ );
2471
+ } catch {
2472
+ throw new BrandConflictError("active palette approval payload is invalid");
2473
+ }
2474
+ const expectedSource = approval.reviewedProposal.provenance.sourceAsset ? "extracted" : expectedSeed ? "derived" : "manual";
2475
+ 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(
2476
+ "active palette does not match its approved proposal"
2477
+ );
2478
+ return { palette, receipt: approval };
2479
+ }
2480
+ async function approvedTypography(ctx, book) {
2481
+ const key = `${book.id}:typography`;
2482
+ const result = await ctx.db.query({
2483
+ [BRAND_NS.section]: {
2484
+ $: { where: { key } },
2485
+ book: {}
2486
+ }
2487
+ });
2488
+ const section = (result[BRAND_NS.section] ?? [])[0];
2489
+ if (!section || section.status !== "approved") return {};
2490
+ 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(
2491
+ "approved typography is missing valid approval provenance"
2492
+ );
2493
+ assertSectionContent("typography", section.content);
2494
+ return {
2495
+ section,
2496
+ receipt: await receipt(
2497
+ ctx,
2498
+ section.approvalReceiptId,
2499
+ section.approvalActionDigest,
2500
+ { bookId: book.id, kind: "typography", content: section.content }
2501
+ )
2502
+ };
2503
+ }
2504
+ var receiptGuard = (value) => ({
2505
+ ns: BRAND_NS.approvalReceipt,
2506
+ id: value.id,
2507
+ exists: true,
2508
+ equals: {
2509
+ actionDigest: value.actionDigest,
2510
+ receiptDigest: value.receiptDigest,
2511
+ resolution: "accepted"
2512
+ }
2513
+ });
2514
+
2515
+ // src/routes/proposal-state.ts
2516
+ async function proposalDecisionState(ctx, req, book, proposal, resolution, receiptId, paletteId) {
2517
+ const needsPalette = resolution === "accepted" && (proposal.kind === "palette" || proposal.kind === "typography");
2518
+ const paletteDep = needsPalette ? await activePalette(ctx, book) : {};
2519
+ const typeDep = resolution === "accepted" && proposal.kind === "palette" ? await approvedTypography(ctx, book) : {};
2520
+ const dependencyGuards = [];
2521
+ const source = proposal.provenance.sourceAsset;
2522
+ let sourceAsset;
2523
+ if (source && resolution === "accepted") {
2524
+ const result = await ctx.db.query({
2525
+ [BRAND_NS.asset]: {
2526
+ $: { where: { id: source.assetId, bookId: book.id } },
2527
+ book: {}
2528
+ }
2529
+ });
2530
+ sourceAsset = (result[BRAND_NS.asset] ?? [])[0];
2531
+ 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(
2532
+ "source asset changed since the proposal was reviewed"
2533
+ );
2534
+ if (!await ctx.verifySourceAssetSnapshot({
2535
+ req,
2536
+ appId: ctx.appId,
2537
+ bookId: book.id,
2538
+ assetId: source.assetId,
2539
+ path: sourceAsset.path,
2540
+ snapshot: source
2541
+ })) throw new BrandConflictError(
2542
+ "source asset bytes changed since the proposal was reviewed"
2543
+ );
2544
+ dependencyGuards.push({
2545
+ ns: BRAND_NS.asset,
2546
+ id: sourceAsset.id,
2547
+ exists: true,
2548
+ equals: {
2549
+ bookId: book.id,
2550
+ status: "live",
2551
+ path: sourceAsset.path,
2552
+ storageObjectId: sourceAsset.storageObjectId,
2553
+ contentDigest: source.contentDigest,
2554
+ contentType: source.contentType,
2555
+ size: source.objectSize,
2556
+ analysisRevision: source.analysisRevision,
2557
+ ...source.analysisDigest ? { analysisDigest: source.analysisDigest } : {}
2558
+ }
2559
+ });
2560
+ }
2561
+ if (paletteDep.palette && paletteDep.receipt) {
2562
+ dependencyGuards.push({
2563
+ ns: BRAND_NS.palette,
2564
+ id: paletteDep.palette.id,
2565
+ exists: true,
2566
+ equals: {
2567
+ bookId: book.id,
2568
+ status: "active",
2569
+ name: paletteDep.palette.name,
2570
+ swatches: paletteDep.palette.swatches,
2571
+ seedHex: paletteDep.palette.seedHex ?? null,
2572
+ source: paletteDep.palette.source,
2573
+ rationale: paletteDep.palette.rationale,
2574
+ proposalId: paletteDep.palette.proposalId,
2575
+ approvalReceiptId: paletteDep.palette.approvalReceiptId,
2576
+ approvalActionDigest: paletteDep.palette.approvalActionDigest,
2577
+ audience: paletteDep.palette.audience,
2578
+ createdAt: paletteDep.palette.createdAt,
2579
+ updatedAt: paletteDep.palette.updatedAt
2580
+ }
2581
+ }, receiptGuard(paletteDep.receipt));
2582
+ }
2583
+ if (typeDep.section && typeDep.receipt) {
2584
+ dependencyGuards.push({
2585
+ ns: BRAND_NS.section,
2586
+ id: typeDep.section.id,
2587
+ exists: true,
2588
+ equals: {
2589
+ key: typeDep.section.key,
2590
+ bookId: book.id,
2591
+ kind: "typography",
2592
+ status: "approved",
2593
+ content: typeDep.section.content,
2594
+ approvalReceiptId: typeDep.section.approvalReceiptId,
2595
+ approvalActionDigest: typeDep.section.approvalActionDigest,
2596
+ updatedAt: typeDep.section.updatedAt
2597
+ }
2598
+ }, receiptGuard(typeDep.receipt));
2599
+ }
2600
+ let approvedContent = null;
2601
+ let paletteResult = null;
2602
+ if (resolution === "accepted" && proposal.kind === "palette") {
2603
+ if (!paletteId) throw new BrandNotFoundError("resulting palette id");
2604
+ paletteResult = {
2605
+ id: paletteId,
2606
+ name: capString(proposal.payload.name, "payload.name", 120),
2607
+ swatches: assertSwatches(proposal.payload.swatches),
2608
+ seedHex: proposal.payload.seedHex ?? null,
2609
+ receiptId,
2610
+ archivePaletteId: paletteDep.palette?.id ?? null
2611
+ };
2612
+ } else if (resolution === "accepted") {
2613
+ approvedContent = assertSectionContent(
2614
+ proposal.kind,
2615
+ proposal.payload.content
2616
+ );
2617
+ }
2618
+ const tokenSources = resolution === "accepted" && (proposal.kind === "palette" || proposal.kind === "typography") ? {
2619
+ paletteReceiptId: proposal.kind === "palette" ? receiptId : paletteDep.palette?.approvalReceiptId ?? null,
2620
+ paletteActionDigest: proposal.kind === "palette" ? "this-action" : paletteDep.palette?.approvalActionDigest ?? null,
2621
+ typographyReceiptId: proposal.kind === "typography" ? receiptId : typeDep.section?.approvalReceiptId ?? null,
2622
+ typographyActionDigest: proposal.kind === "typography" ? "this-action" : typeDep.section?.approvalActionDigest ?? null
2623
+ } : null;
2624
+ const effectDescriptor = {
2625
+ proposalId: proposal.id,
2626
+ resolution,
2627
+ nextBookVersion: resolution === "accepted" ? book.version + 1 : null,
2628
+ nextActivationRevision: resolution === "accepted" ? book.activationRevision + 1 : null,
2629
+ resultingActivePaletteId: proposal.kind === "palette" && resolution === "accepted" ? paletteId ?? null : book.activePaletteId ?? null,
2630
+ palette: paletteResult,
2631
+ approvedSection: approvedContent ? { kind: proposal.kind, content: approvedContent, receiptId } : null,
2632
+ tokenSources
2633
+ };
2634
+ const binding = {
2635
+ version: 1,
2636
+ bookVersion: book.version,
2637
+ activationRevision: book.activationRevision,
2638
+ activePaletteId: book.activePaletteId ?? null,
2639
+ memberIdsDigest: await brandJsonDigest(book.memberIds),
2640
+ activePaletteDigest: paletteDep.palette ? await brandJsonDigest({
2641
+ id: paletteDep.palette.id,
2642
+ swatches: paletteDep.palette.swatches,
2643
+ approvalReceiptId: paletteDep.palette.approvalReceiptId,
2644
+ approvalActionDigest: paletteDep.palette.approvalActionDigest
2645
+ }) : null,
2646
+ typographyDigest: typeDep.section ? await brandJsonDigest({
2647
+ key: typeDep.section.key,
2648
+ content: typeDep.section.content,
2649
+ approvalReceiptId: typeDep.section.approvalReceiptId,
2650
+ approvalActionDigest: typeDep.section.approvalActionDigest
2651
+ }) : null,
2652
+ sourceAssetDigest: source ? await brandJsonDigest(source) : null,
2653
+ effectDigest: await brandJsonDigest(effectDescriptor)
2654
+ };
2655
+ return {
2656
+ ...paletteDep.palette ? { priorPalette: paletteDep.palette } : {},
2657
+ ...typeDep.section ? { approvedTypography: typeDep.section } : {},
2658
+ binding,
2659
+ effectDescriptor,
2660
+ dependencyGuards
2661
+ };
2662
+ }
2663
+
2664
+ // src/routes/proposals.ts
2665
+ var record4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2666
+ var sameStrings2 = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);
2667
+ var guardFailure2 = (error) => record4(error) && error.code === "transact_guard_failed";
2668
+ async function handleProposalResolution(ctx, req, bookId, proposalId) {
2669
+ if (req.method !== "POST") return methodNotAllowed();
2670
+ const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
2671
+ if (ctx.actor.kind !== "human")
2672
+ throw new BrandForbiddenError("only a directly authenticated human can resolve a proposal");
2673
+ const body = await readJson(req);
2674
+ const {
2675
+ mutationId,
2676
+ resolution,
2677
+ reviewed,
2678
+ resolutionNote
2679
+ } = parseProposalResolutionRequest(body, bookId, proposalId);
2680
+ const mutationKey = await proposalResolutionMutationKey(
2681
+ bookId,
2682
+ proposalId,
2683
+ mutationId
2684
+ );
2685
+ const stable = (await brandJsonDigest({ mutationKey })).slice(7);
2686
+ const receiptId = `brand-receipt-${stable.slice(0, 32)}`;
2687
+ const paletteId = resolution === "accepted" && reviewed.kind === "palette" ? `brand-palette-${stable.slice(32)}` : void 0;
2688
+ const existing = await proposalReceiptByMutation(ctx, mutationKey);
2689
+ if (existing) {
2690
+ if (!await verifyBrandApprovalReceipt(existing) || !sameProposalResolutionRequest(
2691
+ existing,
2692
+ reviewed,
2693
+ resolution,
2694
+ resolutionNote,
2695
+ paletteId
2696
+ )) throw new BrandConflictError("mutationId was used for another decision");
2697
+ const replayed = await consumeProposalAuthority(
2698
+ ctx,
2699
+ req,
2700
+ bookId,
2701
+ proposalId,
2702
+ existing.actionDigest,
2703
+ mutationKey
2704
+ );
2705
+ if (canonicalBrandJson(replayed) !== canonicalBrandJson(existing.authorityConsumption)) throw new BrandConflictError("central approval receipt changed");
2706
+ return json({ receipt: existing, duplicate: true });
2707
+ }
2708
+ const result = await ctx.db.query({
2709
+ [BRAND_NS.proposal]: {
2710
+ $: { where: { id: proposalId, bookId } },
2711
+ book: {}
2712
+ }
2713
+ });
2714
+ const proposal = (result[BRAND_NS.proposal] ?? [])[0];
2715
+ 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}`);
2716
+ const current = proposalReviewSnapshot(proposal);
2717
+ const { reviewDigest, ...unsignedReview } = current;
2718
+ if (await brandJsonDigest(unsignedReview) !== reviewDigest || canonicalBrandJson(current) !== canonicalBrandJson(reviewed)) throw new BrandConflictError("proposal changed since review");
2719
+ if (resolution === "accepted" && !sameStrings2(current.audience, book.memberIds))
2720
+ throw new BrandConflictError("book membership changed; review the proposal again");
2721
+ const state = await proposalDecisionState(
2722
+ ctx,
2723
+ req,
2724
+ book,
2725
+ proposal,
2726
+ resolution,
2727
+ receiptId,
2728
+ paletteId
2729
+ );
2730
+ const actionDigest = await brandJsonDigest({
2731
+ version: 1,
2732
+ reviewedProposal: current,
2733
+ resolution,
2734
+ resolutionNote: resolutionNote ?? null,
2735
+ paletteId: paletteId ?? null,
2736
+ decisionBinding: state.binding
2737
+ });
2738
+ const authority = await consumeProposalAuthority(
2739
+ ctx,
2740
+ req,
2741
+ bookId,
2742
+ proposalId,
2743
+ actionDigest,
2744
+ mutationKey
2745
+ );
2746
+ const receiptBase = {
2747
+ version: 1,
2748
+ id: receiptId,
2749
+ mutationKey,
2750
+ bookId,
2751
+ proposalId,
2752
+ resolution,
2753
+ ...paletteId ? { paletteId } : {},
2754
+ ...resolutionNote ? { resolutionNote } : {},
2755
+ reviewedProposal: current,
2756
+ actionDigest,
2757
+ decisionBinding: state.binding,
2758
+ approvedBy: ctx.actor.id,
2759
+ approvedByKind: "human",
2760
+ appliedBy: ctx.actor.id,
2761
+ appliedByKind: "human",
2762
+ authorityRef: brandAuthorityRef(authority),
2763
+ authorityCapability: "brand.approve",
2764
+ authorityConsumption: authority,
2765
+ createdAt: authority.consumedAt
2766
+ };
2767
+ const receipt2 = {
2768
+ ...receiptBase,
2769
+ receiptDigest: await brandJsonDigest(receiptBase)
2770
+ };
2771
+ const ops = await proposalDecisionOps({
2772
+ proposal: { ...proposal, audience: [...book.memberIds] },
2773
+ book,
2774
+ resolution,
2775
+ receiptId,
2776
+ actionDigest,
2777
+ ...paletteId ? { paletteId } : {},
2778
+ ...state.priorPalette ? { priorPalette: state.priorPalette } : {},
2779
+ ...state.approvedTypography ? { approvedTypography: state.approvedTypography } : {},
2780
+ actorId: ctx.actor.id,
2781
+ now: authority.consumedAt,
2782
+ resolutionNote
2783
+ });
2784
+ ops.push({
2785
+ t: "update",
2786
+ ns: BRAND_NS.approvalReceipt,
2787
+ id: receipt2.id,
2788
+ attrs: receipt2
2789
+ }, {
2790
+ t: "link",
2791
+ ns: BRAND_NS.approvalReceipt,
2792
+ id: receipt2.id,
2793
+ label: "book",
2794
+ target: bookId
2795
+ });
2796
+ const guards = [
2797
+ {
2798
+ ns: BRAND_NS.proposal,
2799
+ id: proposalId,
2800
+ exists: true,
2801
+ equals: proposalGuardEquals(current)
2802
+ },
2803
+ { ns: BRAND_NS.approvalReceipt, id: receiptId, exists: false },
2804
+ {
2805
+ ns: BRAND_NS.book,
2806
+ id: bookId,
2807
+ exists: true,
2808
+ equals: {
2809
+ version: book.version,
2810
+ activationRevision: book.activationRevision,
2811
+ memberIds: book.memberIds
2812
+ }
2813
+ },
2814
+ ...state.dependencyGuards
2815
+ ];
2816
+ if (paletteId) {
2817
+ guards.push({ ns: BRAND_NS.palette, id: paletteId, exists: false });
2818
+ }
2819
+ try {
2820
+ const tx = await ctx.db.transact(ops, {
2821
+ mutationId: mutationKey,
2822
+ guards,
2823
+ asUser: ctx.actor.id,
2824
+ ...ctx.actor.email ? { asEmail: ctx.actor.email } : {},
2825
+ asPrincipalKind: "human"
2826
+ });
2827
+ if (tx.duplicate) {
2828
+ const replay = await proposalReceiptByMutation(ctx, mutationKey);
2829
+ if (!replay || !await verifyBrandApprovalReceipt(replay) || !sameProposalResolutionRequest(
2830
+ replay,
2831
+ reviewed,
2832
+ resolution,
2833
+ resolutionNote,
2834
+ paletteId
2835
+ ) || canonicalBrandJson(replay.authorityConsumption) !== canonicalBrandJson(authority))
2836
+ throw new BrandConflictError("proposal decision replay is invalid");
2837
+ return json({ receipt: replay, duplicate: true });
2838
+ }
2839
+ return json({ receipt: receipt2, duplicate: false });
2840
+ } catch (error) {
2841
+ if (guardFailure2(error)) throw new BrandReviewStateChangedError();
2842
+ throw error;
2843
+ }
2844
+ }
2845
+
1387
2846
  // src/routes/tokens.ts
1388
- var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1389
- async function resolveTokenMaps(db, book) {
2847
+ var record5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2848
+ var missing = (bookId) => {
2849
+ throw new BrandNotFoundError(`compiled tokens for brand book ${bookId}`);
2850
+ };
2851
+ async function acceptedReceipt(db, bookId, receiptId, actionDigest) {
2852
+ const result = await db.query({
2853
+ [BRAND_NS.approvalReceipt]: {
2854
+ $: { where: { id: receiptId, bookId } },
2855
+ book: {}
2856
+ }
2857
+ });
2858
+ const receipt2 = (result[BRAND_NS.approvalReceipt] ?? [])[0];
2859
+ if (!receipt2 || receipt2.resolution !== "accepted" || receipt2.actionDigest !== actionDigest || !Array.isArray(receipt2.book) || receipt2.book.length !== 1 || receipt2.book[0]?.id !== bookId) return missing(bookId);
2860
+ const { book: _book, ...unhydrated } = receipt2;
2861
+ if (!await verifyBrandApprovalReceipt(unhydrated)) return missing(bookId);
2862
+ return unhydrated;
2863
+ }
2864
+ async function approvedCache(db, book) {
1390
2865
  const cache = book.tokens;
1391
- if (cache && isRecord2(cache.light) && isRecord2(cache.dark))
1392
- return { light: cache.light, dark: cache.dark };
1393
- if (!book.activePaletteId) throw new BrandNotFoundError(`compiled tokens for brand book ${book.id}`);
1394
- const pres = await db.query({
1395
- [BRAND_NS.palette]: { $: { where: { id: book.activePaletteId } } }
2866
+ if (!cache || !record5(cache.light) || !record5(cache.dark) || !book.activePaletteId || cache.paletteId !== book.activePaletteId || !cache.paletteReceiptId || !cache.paletteActionDigest || !cache.sourceDigest) return missing(book.id);
2867
+ const paletteResult = await db.query({
2868
+ [BRAND_NS.palette]: {
2869
+ $: { where: { id: cache.paletteId, bookId: book.id } },
2870
+ book: {}
2871
+ }
2872
+ });
2873
+ const palette = (paletteResult[BRAND_NS.palette] ?? [])[0];
2874
+ 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);
2875
+ await acceptedReceipt(
2876
+ db,
2877
+ book.id,
2878
+ cache.paletteReceiptId,
2879
+ cache.paletteActionDigest
2880
+ );
2881
+ const hasTypeId = cache.typographyReceiptId !== void 0;
2882
+ const hasTypeDigest = cache.typographyActionDigest !== void 0;
2883
+ if (hasTypeId !== hasTypeDigest) return missing(book.id);
2884
+ const sectionResult = await db.query({
2885
+ [BRAND_NS.section]: {
2886
+ $: { where: { key: `${book.id}:typography` } },
2887
+ book: {}
2888
+ }
1396
2889
  });
1397
- const palette = (pres[BRAND_NS.palette] ?? [])[0];
1398
- if (!palette) throw new BrandNotFoundError(`compiled tokens for brand book ${book.id}`);
1399
- const sres = await db.query({
1400
- [BRAND_NS.section]: { $: { where: { key: `${book.id}:typography` } } }
2890
+ const typography = (sectionResult[BRAND_NS.section] ?? [])[0];
2891
+ if (hasTypeId) {
2892
+ 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);
2893
+ await acceptedReceipt(
2894
+ db,
2895
+ book.id,
2896
+ cache.typographyReceiptId,
2897
+ cache.typographyActionDigest
2898
+ );
2899
+ } else if (typography?.status === "approved" && (typography.approvalReceiptId || typography.approvalActionDigest)) {
2900
+ return missing(book.id);
2901
+ }
2902
+ const sourceDigest = await brandJsonDigest({
2903
+ paletteId: cache.paletteId,
2904
+ paletteReceiptId: cache.paletteReceiptId,
2905
+ paletteActionDigest: cache.paletteActionDigest,
2906
+ typographyReceiptId: cache.typographyReceiptId ?? null,
2907
+ typographyActionDigest: cache.typographyActionDigest ?? null
1401
2908
  });
1402
- const typography = (sres[BRAND_NS.section] ?? [])[0]?.content;
1403
- const compiled = compileBrandTokens({ swatches: palette.swatches, typography });
1404
- return { light: compiled.light, dark: compiled.dark };
2909
+ if (sourceDigest !== cache.sourceDigest) return missing(book.id);
2910
+ return cache;
1405
2911
  }
1406
2912
  async function handleTokens(db, req, bookId, which, actor) {
1407
2913
  if (req.method !== "GET") return methodNotAllowed();
1408
2914
  const book = actor === null ? await loadBook(db, bookId) : await loadMemberBook(db, bookId, actor.id);
1409
- const maps = await resolveTokenMaps(db, book);
1410
- if (which === "tokens.json") return json({ light: maps.light, dark: maps.dark });
1411
- const etag = `"brand-tokens-${book.updatedAt}"`;
1412
- const inm = req.headers.get("if-none-match");
1413
- if (inm && inm.split(",").map((v) => v.trim()).includes(etag))
1414
- return new Response(null, { status: 304, headers: { etag } });
1415
- return new Response(renderTokensCss(maps.light, { dark: maps.dark }), {
2915
+ const cache = await approvedCache(db, book);
2916
+ const light = cache.light;
2917
+ const dark = cache.dark;
2918
+ if (which === "tokens.json") return json({ light, dark });
2919
+ const etag = `"brand-tokens-${book.activationRevision}-${cache.sourceDigest.slice(7)}"`;
2920
+ if (req.headers.get("if-none-match")?.split(",").map((value) => value.trim()).includes(etag)) return new Response(null, { status: 304, headers: { etag } });
2921
+ return new Response(renderTokensCss(light, { dark }), {
1416
2922
  status: 200,
1417
- headers: { "content-type": "text/css; charset=utf-8", etag }
2923
+ headers: {
2924
+ "content-type": "text/css; charset=utf-8",
2925
+ "cache-control": "public, max-age=0, must-revalidate",
2926
+ etag
2927
+ }
2928
+ });
2929
+ }
2930
+
2931
+ // src/routes/discussion-references.ts
2932
+ var capLimit = (raw) => {
2933
+ const parsed = Number(raw ?? 20);
2934
+ return Number.isInteger(parsed) ? Math.max(1, Math.min(parsed, 20)) : 20;
2935
+ };
2936
+ var targetFromQuery = (url) => {
2937
+ const kind = url.searchParams.get("kind");
2938
+ const id = url.searchParams.get("id");
2939
+ if (!kind && !id) return null;
2940
+ if (!kind || !id) return null;
2941
+ const value = new URL("https://brand.invalid");
2942
+ value.searchParams.set("odla-ref", `${kind}/${id}`);
2943
+ return parseBrandDiscussionReference(value);
2944
+ };
2945
+ var childNamespace = (kind) => ({
2946
+ "brand:asset": BRAND_NS.asset,
2947
+ "brand:palette": BRAND_NS.palette,
2948
+ "brand:proposal": BRAND_NS.proposal,
2949
+ "brand:receipt": BRAND_NS.approvalReceipt
2950
+ })[kind] ?? null;
2951
+ var childTarget = (kind, row) => ({
2952
+ kind,
2953
+ bookId: row.bookId,
2954
+ resourceId: row.id
2955
+ });
2956
+ var projection = (req, ctx, book, target, row) => {
2957
+ let label = book.name;
2958
+ let hint = `${book.status} brand book`;
2959
+ let summary = `${book.name} brand book`;
2960
+ let status = book.status;
2961
+ let destination = "Brand Studio \xB7 Brand book";
2962
+ if (target.kind === "brand:asset") {
2963
+ const asset = row;
2964
+ label = asset.title?.trim() || `${asset.kind} asset`;
2965
+ hint = `${book.name} \xB7 ${asset.status}`;
2966
+ summary = `${asset.kind} source asset in ${book.name}`;
2967
+ status = asset.status;
2968
+ destination = "Brand Studio \xB7 Conversation";
2969
+ } else if (target.kind === "brand:palette") {
2970
+ const palette = row;
2971
+ label = palette.name;
2972
+ hint = `${book.name} \xB7 ${palette.status} palette`;
2973
+ summary = `${palette.swatches.length}-color palette in ${book.name}`;
2974
+ status = palette.status;
2975
+ destination = "Brand Studio \xB7 Review changes";
2976
+ } else if (target.kind === "brand:proposal") {
2977
+ const proposal = row;
2978
+ label = `${proposal.kind} proposal`;
2979
+ hint = `${book.name} \xB7 ${proposal.status}`;
2980
+ summary = proposal.rationale;
2981
+ status = proposal.status;
2982
+ destination = "Brand Studio \xB7 Review changes";
2983
+ } else if (target.kind === "brand:receipt") {
2984
+ const receipt2 = row;
2985
+ label = `${receipt2.resolution} ${receipt2.reviewedProposal.kind} change`;
2986
+ hint = `${book.name} \xB7 approval receipt`;
2987
+ summary = `${receipt2.resolution} ${receipt2.reviewedProposal.kind} decision`;
2988
+ status = receipt2.resolution;
2989
+ destination = "Brand Studio \xB7 Review changes";
2990
+ }
2991
+ return {
2992
+ kind: target.kind,
2993
+ id: brandDiscussionReferenceId(target),
2994
+ label,
2995
+ hint,
2996
+ summary,
2997
+ status,
2998
+ destination,
2999
+ href: brandDiscussionReferenceHref(
3000
+ new URL(req.url),
3001
+ target,
3002
+ ctx.discussionBasePath
3003
+ )
3004
+ };
3005
+ };
3006
+ async function exact(ctx, req, target) {
3007
+ const book = await loadMemberBook(ctx.db, target.bookId, ctx.actor.id).catch(() => null);
3008
+ if (!book) return [];
3009
+ if (target.kind === "brand:book") {
3010
+ return [projection(req, ctx, book, target, book)];
3011
+ }
3012
+ const namespace = childNamespace(target.kind);
3013
+ if (!namespace || !target.resourceId) return [];
3014
+ const result = await ctx.db.query({
3015
+ [namespace]: {
3016
+ $: { where: { id: target.resourceId }, limit: 1 }
3017
+ }
3018
+ });
3019
+ const row = (result[namespace] ?? [])[0];
3020
+ if (!row || row.bookId !== book.id || target.kind === "brand:asset" && row.status !== "live") return [];
3021
+ return [projection(req, ctx, book, target, row)];
3022
+ }
3023
+ var matches = (item, needle) => !needle || `${item.label}
3024
+ ${item.hint}
3025
+ ${item.id}`.toLowerCase().includes(needle);
3026
+ async function search(ctx, req, url) {
3027
+ const result = await ctx.db.query({
3028
+ [BRAND_NS.book]: { $: { order: { updatedAt: "desc" }, limit: 100 } },
3029
+ [BRAND_NS.asset]: { $: { order: { createdAt: "desc" }, limit: 100 } },
3030
+ [BRAND_NS.palette]: { $: { order: { updatedAt: "desc" }, limit: 100 } },
3031
+ [BRAND_NS.proposal]: { $: { order: { createdAt: "desc" }, limit: 100 } },
3032
+ [BRAND_NS.approvalReceipt]: {
3033
+ $: { order: { createdAt: "desc" }, limit: 100 }
3034
+ }
3035
+ });
3036
+ const books = (result[BRAND_NS.book] ?? []).filter((book) => isMember(book, ctx.actor.id));
3037
+ const bookById = new Map(books.map((book) => [book.id, book]));
3038
+ const items = books.map(
3039
+ (book) => projection(req, ctx, book, { kind: "brand:book", bookId: book.id }, book)
3040
+ );
3041
+ const append = (kind, rows) => {
3042
+ for (const row of rows) {
3043
+ const book = bookById.get(row.bookId);
3044
+ if (!book || kind === "brand:asset" && row.status !== "live") continue;
3045
+ items.push(projection(req, ctx, book, childTarget(kind, row), row));
3046
+ }
3047
+ };
3048
+ append("brand:asset", result[BRAND_NS.asset] ?? []);
3049
+ append("brand:palette", result[BRAND_NS.palette] ?? []);
3050
+ append("brand:proposal", result[BRAND_NS.proposal] ?? []);
3051
+ append("brand:receipt", result[BRAND_NS.approvalReceipt] ?? []);
3052
+ const needle = (url.searchParams.get("q") ?? "").trim().toLowerCase().slice(0, 120);
3053
+ return items.filter((item) => matches(item, needle)).slice(0, capLimit(url.searchParams.get("limit")));
3054
+ }
3055
+ async function handleBrandDiscussionReferences(ctx, req, url) {
3056
+ if (req.method !== "GET") return methodNotAllowed();
3057
+ const kind = url.searchParams.get("kind");
3058
+ const id = url.searchParams.get("id");
3059
+ if (kind && !id || !kind && id) return json({ error: "kind and id must be paired" }, 400);
3060
+ const target = targetFromQuery(url);
3061
+ if ((kind || id) && !target) return json({ items: [] });
3062
+ return json({
3063
+ items: target ? await exact(ctx, req, target) : await search(ctx, req, url)
1418
3064
  });
1419
3065
  }
1420
3066
 
1421
3067
  // src/routes/index.ts
1422
3068
  var tokensFile = (seg) => seg.length === 3 && seg[0] === "books" && (seg[2] === "tokens.css" || seg[2] === "tokens.json") ? seg[2] : null;
1423
- async function route(ctx, req, seg) {
1424
- const [head, id, sub, subId] = seg;
3069
+ async function route(ctx, req, url, seg) {
3070
+ const [head, id, sub, subId, action] = seg;
3071
+ if (head === "discussion-references" && seg.length === 1)
3072
+ return handleBrandDiscussionReferences(ctx, req, url);
1425
3073
  if (head !== "books") return null;
1426
3074
  if (seg.length === 1) return handleBooksRoot(ctx, req);
1427
3075
  if (seg.length === 2) return handleBookItem(ctx, req, id);
3076
+ if (seg.length === 3 && sub === "members") return handleBookMembers(ctx, req, id);
1428
3077
  if (sub === "assets") {
1429
3078
  if (seg.length === 3) return handleAssetsRoot(ctx, req, id);
1430
3079
  if (seg.length === 4) return handleAssetItem(ctx, req, id, subId);
3080
+ if (seg.length === 5 && action === "content")
3081
+ return handleAssetContent(ctx, req, id, subId);
1431
3082
  return null;
1432
3083
  }
3084
+ if (sub === "proposals" && seg.length === 5 && action === "resolve")
3085
+ return handleProposalResolution(ctx, req, id, subId);
1433
3086
  const file = tokensFile(seg);
1434
3087
  if (file) return handleTokens(ctx.db, req, id, file, ctx.actor);
1435
3088
  return null;
@@ -1439,9 +3092,14 @@ function createBrandRoutes(options) {
1439
3092
  const publicTokens = options.publicTokens === true;
1440
3093
  const base = {
1441
3094
  db: options.db,
3095
+ appId: options.appId,
3096
+ authorizeCapability: options.authorizeCapability,
3097
+ consumeHumanExact: options.consumeHumanExact,
3098
+ verifySourceAssetSnapshot: options.verifySourceAssetSnapshot,
1442
3099
  now: options.now ?? Date.now,
1443
3100
  newId: options.newId ?? (() => crypto.randomUUID()),
1444
- maxUploadBytes: options.maxUploadBytes ?? 8 * 1024 * 1024
3101
+ maxUploadBytes: options.maxUploadBytes ?? 8 * 1024 * 1024,
3102
+ discussionBasePath: options.discussionBasePath ?? "/"
1445
3103
  };
1446
3104
  return async (req) => {
1447
3105
  const url = new URL(req.url);
@@ -1450,15 +3108,44 @@ function createBrandRoutes(options) {
1450
3108
  try {
1451
3109
  const file = tokensFile(seg);
1452
3110
  if (publicTokens && file) return await handleTokens(base.db, req, seg[1], file, null);
1453
- const actor = await options.authorize(req);
3111
+ const actor = await (seg[0] === "discussion-references" ? options.authorizeDiscussionReferences ?? options.authorize : options.authorize)(req);
1454
3112
  if (!actor) return json({ error: "unauthorized" }, 401);
1455
- return await route({ ...base, actor }, req, seg) ?? json({ error: "not found" }, 404);
3113
+ return await route({ ...base, actor }, req, url, seg) ?? json({ error: "not found" }, 404);
1456
3114
  } catch (error) {
1457
3115
  return errorResponse(error);
1458
3116
  }
1459
3117
  };
1460
3118
  }
1461
3119
 
3120
+ // src/dispatch-assets.ts
3121
+ var MAX_MESSAGE_ASSETS = 12;
3122
+ function candidateAssetIds(row) {
3123
+ const standard = Array.isArray(row.attachments) ? row.attachments.flatMap((value) => {
3124
+ if (!value || typeof value !== "object" || Array.isArray(value))
3125
+ return [];
3126
+ const id = value.id;
3127
+ return typeof id === "string" && id.length > 0 && id.length <= 200 ? [id] : [];
3128
+ }) : [];
3129
+ const legacy = Array.isArray(row.assetIds) ? row.assetIds.filter((value) => typeof value === "string" && value.length > 0 && value.length <= 200) : [];
3130
+ return [.../* @__PURE__ */ new Set([...standard, ...legacy])].slice(0, MAX_MESSAGE_ASSETS);
3131
+ }
3132
+ async function attachedBrandAssetIds(db, book, row) {
3133
+ const candidates = candidateAssetIds(row);
3134
+ const assets = await Promise.all(candidates.map(async (id) => {
3135
+ const result = await db.query({
3136
+ [BRAND_NS.asset]: {
3137
+ $: { where: { id, bookId: book.id, status: "live" }, limit: 2 },
3138
+ book: { $: { limit: 2 } }
3139
+ }
3140
+ });
3141
+ const rows = result[BRAND_NS.asset] ?? [];
3142
+ if (rows.length !== 1) return null;
3143
+ const asset = rows[0];
3144
+ 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;
3145
+ }));
3146
+ return assets.filter((id) => id !== null);
3147
+ }
3148
+
1462
3149
  // src/triggers.ts
1463
3150
  var CHAT_MESSAGE_NS = "chat_message";
1464
3151
  var celString = (value) => value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
@@ -1480,73 +3167,133 @@ function brandBotTrigger(opts) {
1480
3167
  // src/dispatch.ts
1481
3168
  function parseBrandDispatch(raw) {
1482
3169
  if (!raw || typeof raw !== "object") return null;
1483
- const b = raw;
1484
- const t = b.trigger;
1485
- const e = b.event;
1486
- if (typeof b.appId !== "string" || !b.appId) return null;
1487
- if (!t || typeof t.runAs?.agentId !== "string" || typeof t.runAs?.persona !== "string") return null;
1488
- if (!e || typeof e.id !== "string" || !e.row || typeof e.row !== "object") return null;
3170
+ const body = raw;
3171
+ const trigger = body.trigger;
3172
+ const event = body.event;
3173
+ 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;
1489
3174
  return {
1490
- v: typeof b.v === "number" ? b.v : 1,
1491
- appId: b.appId,
3175
+ v: 2,
3176
+ appId: body.appId,
3177
+ appIncarnation: body.appIncarnation,
3178
+ jobId: body.jobId,
1492
3179
  trigger: {
1493
- id: typeof t.id === "string" ? t.id : "",
1494
- skill: typeof t.skill === "string" ? t.skill : "",
1495
- runAs: { agentId: t.runAs.agentId, persona: t.runAs.persona },
1496
- ...typeof t.maxDepth === "number" ? { maxDepth: t.maxDepth } : {}
3180
+ id: typeof trigger.id === "string" ? trigger.id : "",
3181
+ skill: typeof trigger.skill === "string" ? trigger.skill : "",
3182
+ runAs: {
3183
+ agentId: trigger.runAs.agentId,
3184
+ persona: trigger.runAs.persona
3185
+ },
3186
+ ...typeof trigger.maxDepth === "number" ? { maxDepth: trigger.maxDepth } : {}
1497
3187
  },
1498
- event: { ns: typeof e.ns === "string" ? e.ns : CHAT_MESSAGE_NS, id: e.id, row: e.row }
3188
+ event: {
3189
+ ns: typeof event.ns === "string" ? event.ns : CHAT_MESSAGE_NS,
3190
+ id: event.id,
3191
+ row: event.row
3192
+ }
1499
3193
  };
1500
3194
  }
1501
3195
  async function bookForChannel(db, channelId) {
1502
3196
  if (!channelId) return null;
1503
- const res = await db.query({ [BRAND_NS.book]: { $: { where: { channelId } } } });
1504
- return (res[BRAND_NS.book] ?? [])[0] ?? null;
3197
+ const result = await db.query({
3198
+ [BRAND_NS.book]: { $: { where: { channelId } } }
3199
+ });
3200
+ const books = result[BRAND_NS.book] ?? [];
3201
+ if (books.length > 1)
3202
+ throw new BrandConflictError(`multiple brand books claim channel ${channelId}`);
3203
+ return books[0] ?? null;
1505
3204
  }
1506
- function brandInputFor(body, attachments) {
3205
+ function brandInputFor(body, attachments, author, attachedAssetIds = []) {
1507
3206
  const row = body.event.row;
1508
- 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.`;
3207
+ const actor = author ? `${author.displayName} (${author.kind}${author.managerDisplayName ? `, managed by ${author.managerDisplayName}` : ""})` : row.authorKind === "bot" ? "a managed agent" : "a participant";
3208
+ 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.";
1509
3209
  return attachments?.length ? [...attachments, { type: "text", text: prompt }] : prompt;
1510
3210
  }
1511
- var PRETURN_IMAGE_TYPES = /* @__PURE__ */ new Set([
1512
- "image/png",
1513
- "image/jpeg",
1514
- "image/gif",
1515
- "image/webp"
1516
- ]);
1517
- async function assetBlocksFor(db, fileBaseUrl, raw) {
1518
- if (!Array.isArray(raw)) return [];
1519
- const ids = raw.filter((v) => typeof v === "string");
1520
- if (ids.length === 0) return [];
1521
- const res = await db.query({ [BRAND_NS.asset]: { $: { where: { id: { $in: ids } } } } });
1522
- const byId = new Map((res[BRAND_NS.asset] ?? []).map((a2) => [a2.id, a2]));
1523
- const blocks = [];
1524
- for (const id of ids) {
1525
- const asset = byId.get(id);
1526
- if (!asset || asset.deletedAt != null || !PRETURN_IMAGE_TYPES.has(asset.contentType)) continue;
1527
- const url = asset.url.startsWith("http") ? asset.url : fileBaseUrl + asset.url;
1528
- blocks.push({ type: "image", source: { type: "url", url } });
1529
- }
1530
- return blocks;
1531
- }
1532
3211
  async function dispatchBrandTurn(deps, body) {
3212
+ if (body.appId !== deps.appId || body.trigger.skill !== "brand" || body.event.ns !== CHAT_MESSAGE_NS) throw new BrandForbiddenError("foreign or non-brand dispatch");
1533
3213
  const channelId = String(body.event.row.channelId ?? "");
1534
3214
  const book = await bookForChannel(deps.db, channelId);
1535
- if (!book) throw new BrandNotFoundError(`brand book for channel ${channelId || "(missing channelId)"}`);
1536
- const visionInToolResults = supportsBrandVision(deps.catalog?.[deps.model]);
3215
+ if (!book)
3216
+ throw new BrandNotFoundError(
3217
+ `brand book for channel ${channelId || "(missing channelId)"}`
3218
+ );
3219
+ const agentId = body.trigger.runAs.agentId;
3220
+ if (!book.memberIds.includes(agentId))
3221
+ throw new BrandNotFoundError(`brand book for channel ${channelId}`);
3222
+ const read = await deps.authorizeCapability({
3223
+ agentId,
3224
+ bookId: book.id,
3225
+ capability: "brand.read"
3226
+ });
3227
+ if (!read || read.capability !== "brand.read" || !read.authorityRef)
3228
+ throw new BrandForbiddenError("agent lacks live brand.read");
3229
+ const authorId = typeof body.event.row.authorId === "string" ? body.event.row.authorId : "";
3230
+ const authors = authorId ? await deps.resolvePrincipals({
3231
+ requesterAgentId: agentId,
3232
+ bookId: book.id,
3233
+ principalIds: [authorId]
3234
+ }) : [];
3235
+ const author = authors.find((candidate) => candidate.id === authorId);
3236
+ const attachedAssetIds = await attachedBrandAssetIds(
3237
+ deps.db,
3238
+ book,
3239
+ body.event.row
3240
+ );
3241
+ const runtime = await deps.agentRuntimeFor({
3242
+ appId: body.appId,
3243
+ agentId,
3244
+ bookId: book.id,
3245
+ triggerId: body.trigger.id,
3246
+ eventId: body.event.id,
3247
+ jobId: body.jobId
3248
+ });
3249
+ if (!runtime.credentialRef || !runtime.jobId || runtime.jobId !== body.jobId)
3250
+ throw new BrandForbiddenError("agent data credential is not bound");
3251
+ let durableOutcome = false;
3252
+ const bridge = {
3253
+ ...runtime.bridge,
3254
+ createProposal: async (input) => {
3255
+ const proposal = await runtime.bridge.createProposal(input);
3256
+ durableOutcome = true;
3257
+ return proposal;
3258
+ },
3259
+ recordAssetAnalysis: async (input) => {
3260
+ const asset = await runtime.bridge.recordAssetAnalysis(input);
3261
+ durableOutcome = true;
3262
+ return asset;
3263
+ }
3264
+ };
3265
+ const visionInToolResults = supportsBrandVision(
3266
+ deps.catalog?.[deps.model]
3267
+ );
1537
3268
  const self = {
1538
- selfId: body.trigger.runAs.agentId,
3269
+ selfId: agentId,
1539
3270
  kind: "bot",
1540
3271
  displayName: body.trigger.runAs.persona
1541
3272
  };
1542
3273
  const persona = createBrandPersona({
1543
3274
  model: deps.model,
1544
- brand: { db: deps.db, bookId: book.id, self, fileBaseUrl: deps.fileBaseUrl, visionInToolResults },
3275
+ brand: {
3276
+ db: runtime.db,
3277
+ bookId: book.id,
3278
+ self,
3279
+ agentDbBinding: {
3280
+ principalId: agentId,
3281
+ credentialRef: runtime.credentialRef
3282
+ },
3283
+ agentJobId: runtime.jobId,
3284
+ agentBridge: bridge,
3285
+ authorizeCapability: deps.authorizeCapability,
3286
+ resolvePrincipals: deps.resolvePrincipals,
3287
+ visionInToolResults,
3288
+ ...deps.newId ? { newId: deps.newId } : {}
3289
+ },
1545
3290
  skills: deps.chatSkillFor ? [deps.chatSkillFor(channelId, self)] : []
1546
3291
  });
1547
- const attachments = visionInToolResults ? [] : await assetBlocksFor(deps.db, deps.fileBaseUrl, body.event.row.assetIds);
1548
- const run = await deps.runAgent(deps.inference, persona, { input: brandInputFor(body, attachments) });
1549
- return { finalText: run.finalText };
3292
+ const run = await deps.runAgent(deps.inference, persona, {
3293
+ // Models without tool-result vision receive no raw/signed fallback URL.
3294
+ input: brandInputFor(body, void 0, author, attachedAssetIds)
3295
+ });
3296
+ return { finalText: run.finalText, durableOutcome };
1550
3297
  }
1551
3298
 
1552
3299
  // src/descriptor.ts
@@ -1574,10 +3321,12 @@ var brandIntegration = {
1574
3321
  schema: BRAND_SCHEMA,
1575
3322
  rules: brandRules(),
1576
3323
  triggers: [],
3324
+ agentProfile: BRAND_AGENT_PROFILE,
1577
3325
  provision: {
1578
3326
  human: [
3327
+ "Configure direct Clerk human approval through authorizeCapability + consumeHumanExact; delegated, machine, and agent principals must not resolve proposals.",
1579
3328
  '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).',
1580
- "Pick the bot's model; when supportsBrandVision(catalog[model]) is false, dispatch attaches assets pre-turn instead of inside tool results.",
3329
+ "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.",
1581
3330
  "Decide whether compiled tokens are public (publicTokens serves tokens.css/tokens.json unauthenticated)."
1582
3331
  ],
1583
3332
  cli: [
@@ -1585,10 +3334,14 @@ var brandIntegration = {
1585
3334
  "Install brandRules() at /app/:id/admin/rules (merged with the app's existing rules).",
1586
3335
  "Register a brandBotTrigger(...) per bot at /app/:id/admin/triggers \u2014 global mention-gated, or channel-scoped per book.",
1587
3336
  "Seed the bot's agent id into each brand_book.memberIds roster (audienceFanoutOps covers existing child rows).",
1588
- "Provision the bot's AI provider secret (via @odla-ai/ai) and the worker's admin odla-db credential."
3337
+ "Issue the bot brand.read + brand.edit and expose only BRAND_AGENT_PROFILE semantic operations; deny raw brand_* writes and raw file operations.",
3338
+ "Provision private file signing and verifySourceAssetSnapshot; never expose an admin credential or persistent asset URL to the agent."
1589
3339
  ],
1590
3340
  doctor: [
1591
- "All five brand_* namespaces have rules installed (brand_asset fully closed \u2014 worker-mediated only).",
3341
+ "All six brand_* namespaces have rules installed; raw browser/agent writes are denied and child reads use current linked-book membership.",
3342
+ "The agent runtime matches BRAND_AGENT_PROFILE: brand.read/edit only, semantic operations only, no raw brand_* or file access.",
3343
+ "Proposal decisions consume a direct-human exact brand.approve authority use and persist one guarded brand_approval_receipt.",
3344
+ "Private source assets are signed briefly and verifySourceAssetSnapshot revalidates path, ETag, size, content type, and digest before approval.",
1592
3345
  "brand_book.id/slug, brand_section.key, and the other mirrored id attrs are unique.",
1593
3346
  "Each registered trigger's agent id is present in its target books' memberIds rosters.",
1594
3347
  "Books with channelId set point at real chat channels when @odla-ai/chat is installed."
@@ -1615,17 +3368,23 @@ export {
1615
3368
  ASSET_CONTENT_TYPES,
1616
3369
  ASSET_KINDS,
1617
3370
  BOOK_STATUSES,
3371
+ BRAND_AGENT_PROFILE,
1618
3372
  BRAND_CHART_TOKENS,
1619
3373
  BRAND_CHAT_TOKENS,
1620
3374
  BRAND_DERIVED_TOKENS,
3375
+ BRAND_DISCUSSION_REFERENCE_KINDS,
1621
3376
  BRAND_EMITTED_TOKENS,
1622
3377
  BRAND_INSTRUCTIONS,
1623
3378
  BRAND_NS,
1624
3379
  BRAND_REQUIRED_TOKENS,
1625
3380
  BRAND_RULES,
1626
3381
  BRAND_SCHEMA,
3382
+ BrandConflictError,
3383
+ BrandForbiddenError,
3384
+ BrandGoneError,
1627
3385
  BrandInputError,
1628
3386
  BrandNotFoundError,
3387
+ BrandReviewStateChangedError,
1629
3388
  CHART_DELTA_MIN,
1630
3389
  CHAT_MESSAGE_NS,
1631
3390
  CSS_NAMED_COLORS,
@@ -1654,15 +3413,22 @@ export {
1654
3413
  assertSectionContent,
1655
3414
  assertSwatches,
1656
3415
  assetTools,
3416
+ attachedBrandAssetIds,
1657
3417
  audienceFanoutOps,
1658
3418
  base64FromBytes,
3419
+ beginAssetDeleteOps,
1659
3420
  bookForChannel,
1660
3421
  bookTools,
3422
+ brandAuthorityRef,
1661
3423
  brandBotTrigger,
3424
+ brandDiscussionReferenceHref,
3425
+ brandDiscussionReferenceId,
1662
3426
  brandInputFor,
1663
3427
  brandIntegration,
3428
+ brandJsonDigest,
1664
3429
  brandRules,
1665
3430
  brandSkill,
3431
+ canonicalBrandJson,
1666
3432
  capString,
1667
3433
  capStringArray,
1668
3434
  clamp01,
@@ -1681,9 +3447,13 @@ export {
1681
3447
  deriveDarkTokens,
1682
3448
  derivePalette,
1683
3449
  dispatchBrandTurn,
3450
+ finishAssetDeleteOps,
3451
+ formatBrandDiscussionReference,
1684
3452
  hexToOklch,
1685
3453
  hslToRgb,
1686
3454
  inSrgbGamut,
3455
+ isBoundedBrandJson,
3456
+ isBrandHumanAuthorityConsumption,
1687
3457
  linearToSrgb,
1688
3458
  mapPaletteToTokens,
1689
3459
  meetsAA,
@@ -1696,10 +3466,13 @@ export {
1696
3466
  oklchToHex,
1697
3467
  oklchToOklab,
1698
3468
  paletteTools,
3469
+ parseBrandDiscussionReference,
1699
3470
  parseBrandDispatch,
1700
3471
  parseHex,
1701
3472
  pickTextOn,
3473
+ proposalReviewSnapshot,
1702
3474
  proposePaletteOps,
3475
+ proposeSectionOps,
1703
3476
  readTools,
1704
3477
  recordAnalysisOps,
1705
3478
  rejectProposalOps,
@@ -1717,9 +3490,9 @@ export {
1717
3490
  tetradic,
1718
3491
  tintShadeRamp,
1719
3492
  toHex,
1720
- tombstoneAssetOps,
1721
3493
  triadic,
1722
3494
  updateBookOps,
1723
- upsertSectionOps
3495
+ upsertSectionOps,
3496
+ verifyBrandApprovalReceipt
1724
3497
  };
1725
3498
  //# sourceMappingURL=index.js.map