@estiva-app/interop 0.5.0 → 0.7.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/src/projection.ts CHANGED
@@ -370,7 +370,28 @@ export interface ManifestAction {
370
370
  */
371
371
  alsoRead?: number[]
372
372
  }
373
- input?: { type: string; enum?: string }
373
+ input?: {
374
+ type: string
375
+ /** For a scalar input: the vocabulary its value must come from. */
376
+ enum?: string
377
+ /**
378
+ * For `type: 'object'`: the fields a consumer draws, and which are required.
379
+ *
380
+ * **A property's name is the tag its value is written to** (PRO-4). That
381
+ * rule was implicit — Ship's `add-issue` declares `{ title }` and a Ship
382
+ * issue carries `["title", …]`, so it held by coincidence of naming and
383
+ * nothing said so. It is stated here because a consumer cannot construct
384
+ * the event without it, and RFC 0.4 §13.4 asserts the existing declaration
385
+ * is already sufficient for one to try.
386
+ *
387
+ * Its limit, recorded rather than designed around: **nothing can target an
388
+ * event's `content`.** Ship's issue description lives there and is
389
+ * therefore not creatable from another app, which is why `add-issue`
390
+ * declares only a title.
391
+ */
392
+ properties?: Record<string, { type?: string; enum?: string }>
393
+ required?: string[]
394
+ }
374
395
  }
375
396
 
376
397
  /**
@@ -383,6 +404,16 @@ export interface ManifestAction {
383
404
  * them on this side is what lets the React component stay a dumb renderer that
384
405
  * would work for any app.
385
406
  */
407
+ /** One field of an object-creating action's form. */
408
+ export interface ActionFormField {
409
+ /** Also the tag its value is written to — see {@link ManifestAction.input}. */
410
+ name: string
411
+ type: string
412
+ required: boolean
413
+ /** When the property names a vocabulary, its entries, already looked up. */
414
+ options?: { value: string; label: string; colour?: string }[]
415
+ }
416
+
386
417
  export interface ResolvedAction {
387
418
  id: string
388
419
  label: string
@@ -393,9 +424,23 @@ export interface ResolvedAction {
393
424
  * something this version does not recognise — both meaning *unknown*.
394
425
  */
395
426
  effect?: ActionEffect
396
- control: 'select' | 'pubkey' | 'text'
427
+ control: 'select' | 'pubkey' | 'text' | 'form'
397
428
  /** For `select`: the declared vocabulary, already looked up. */
398
429
  options?: { value: string; label: string; colour?: string }[]
430
+ /**
431
+ * For `form`: the fields to draw, in declaration order, with any vocabulary
432
+ * already looked up — the same service `options` performs for a `select`.
433
+ */
434
+ fields?: ActionFormField[]
435
+ /**
436
+ * For `form`: that the new object hangs under the one the action was invoked
437
+ * on (`emits.toAddressOf: 'self'`).
438
+ *
439
+ * Surfaced because it is the other half of "needs a form **and a parent**",
440
+ * which is what made these unrenderable: a consumer drawing only the
441
+ * properties would publish an orphan.
442
+ */
443
+ createsUnder?: string
399
444
  /** The value this field holds right now, so a control can show it. */
400
445
  current?: string
401
446
  field?: string
@@ -422,12 +467,50 @@ function resolveActions(
422
467
  const out: ResolvedAction[] = []
423
468
  for (const action of manifest.actions ?? []) {
424
469
  if (!applies(action)) continue
425
- // Only field-setting changes and comments are renderable today. An action
426
- // that creates a whole new object (`add-issue`) needs a form and a parent,
427
- // so it is skipped rather than drawn as a control that cannot work.
470
+ /*
471
+ Three shapes now, where there were two.
472
+
473
+ A field-setting change and a comment each render as one control. An action
474
+ that creates a whole new object was skipped, because it "needs a form and
475
+ a parent" — and that was true right up until something drew one. It is
476
+ the single most useful cross-app action there is, so the manifest could
477
+ describe it and no app could offer it (PRO-4).
478
+ */
428
479
  const isChange = !!action.emits.field
429
480
  const isComment = action.emits.scope === 'address'
430
- if (!isChange && !isComment) continue
481
+ const isCreation = action.input?.type === 'object' && !!action.input.properties
482
+ if (!isChange && !isComment && !isCreation) continue
483
+
484
+ if (isCreation) {
485
+ const properties = action.input!.properties!
486
+ const required = new Set(action.input!.required ?? [])
487
+ out.push({
488
+ id: action.id,
489
+ label: action.label,
490
+ ...(action.description ? { description: action.description } : {}),
491
+ ...(action.effect ? { effect: action.effect } : {}),
492
+ control: 'form',
493
+ fields: Object.entries(properties).map(([name, spec]) => ({
494
+ name,
495
+ type: spec.type ?? 'string',
496
+ required: required.has(name),
497
+ ...(spec.enum && manifest.vocabularies?.[spec.enum]
498
+ ? {
499
+ options: manifest.vocabularies[spec.enum].map((v) => ({
500
+ value: v.value,
501
+ label: v.label,
502
+ colour: v.colour,
503
+ })),
504
+ }
505
+ : {}),
506
+ })),
507
+ // The parent is not a property and never appears in `properties`; it
508
+ // comes from `emits`, and a form that omitted it would publish an
509
+ // orphan the owning app cannot show anywhere.
510
+ ...(action.emits.toAddressOf ? { createsUnder: action.emits.toAddressOf } : {}),
511
+ })
512
+ continue
513
+ }
431
514
 
432
515
  const vocab = action.input?.enum ? manifest.vocabularies?.[action.input.enum] : undefined
433
516
  out.push({
@@ -720,7 +803,18 @@ function byOrder(a: SignedEvent, b: SignedEvent): number {
720
803
  * several paragraphs establishing is the worst available outcome, because a
721
804
  * blank card reads as "that app is broken". It was never caught because Ship
722
805
  * is the only app that had ever published a manifest, and Ship folds. Nothing
723
- * in RFC 0.4 §13.1 makes `records` mandatory.
806
+ * in RFC 0.4 §13.1 makes `records` mandatory — but SPEC §7 did, and that is the
807
+ * document a stranger implements from. It said an app **MUST** publish a
808
+ * manifest whose content carries `records` and `projections`, so the code was
809
+ * right and the specification was wrong, in a way that would have made anyone
810
+ * following the text declare change events they do not have. SPEC §7.1 now says
811
+ * `records` is OPTIONAL and states the consumer's obligation as a MUST: render
812
+ * the projection, treat every `fold` slot as absent, fall through to its
813
+ * `default`, and do not refuse (PRO-12).
814
+ *
815
+ * The citation is corrected in place rather than deleted because the two
816
+ * documents disagreeing is the thing worth remembering: reasoning from the RFC
817
+ * alone reached the right behaviour and the wrong justification.
724
818
  *
725
819
  * The substitute rule below is only ever used to *fold*, never to query, and
726
820
  * that distinction is load-bearing. The first attempt used `changeKind: -1` as
@@ -745,13 +839,71 @@ function foldRuleOf(manifest: Manifest): RecordsRule {
745
839
  )
746
840
  }
747
841
 
842
+ /**
843
+ * What content model a body is written in — SPEC §13.4.
844
+ *
845
+ * Three values, not two, and not the raw tag string. A consumer has to make a
846
+ * three-way decision and **must not guess the third**:
847
+ *
848
+ * - `marker` — the §13.2 dialect. The default, permanently. 731 published
849
+ * bodies carry no tag and none of them can be given one, so this is not a
850
+ * migration window that closes.
851
+ * - `blocks` — a §13.3 JSON block document.
852
+ * - `unknown` — a format declared after this runtime was written. Render the
853
+ * body as plain text; §13.5 says declining to format is conformant, and
854
+ * parsing it as either known model is what §13 forbids outright.
855
+ *
856
+ * It is resolved here rather than left to each consumer for the same reason
857
+ * `CLOSED_WIDGETS` is: two copies of `tag === undefined ? marker : tag ===
858
+ * 'estiva-blocks-1' ? blocks : text` disagree the first time a third format
859
+ * exists, and the disagreement shows up as one app rendering JSON at a person.
860
+ */
861
+ export type ContentFormat = 'marker' | 'blocks' | 'unknown'
862
+
863
+ /**
864
+ * The one slot in SPEC §7.2's closed set that means "structured content".
865
+ *
866
+ * Named rather than inlined because the runtime keys behaviour on it twice —
867
+ * whether a value carries a content format, and whether it may be truncated —
868
+ * and a consumer reads `slots.body` to find it.
869
+ */
870
+ export const BODY_SLOT = 'body'
871
+
872
+ /** The tag SPEC §13.4 defines. Absence is a declaration, not an omission. */
873
+ export const CONTENT_FORMAT_TAG = 'content-format'
874
+
875
+ /** The one format §13.3 names today. */
876
+ export const BLOCK_DOCUMENT_FORMAT = 'estiva-blocks-1'
877
+
878
+ /**
879
+ * The content model an event's body is in.
880
+ *
881
+ * **Decided by the tag alone.** §13.4 is explicit that a reader MUST NOT decide
882
+ * by inspecting the body: a legacy description that happens to begin with `{`
883
+ * is marker text, because it carries no tag.
884
+ */
885
+ export function contentFormatOf(event: SignedEvent): ContentFormat {
886
+ const declared = tagValue(event, CONTENT_FORMAT_TAG)
887
+ if (declared === undefined || declared === '') return 'marker'
888
+ return declared === BLOCK_DOCUMENT_FORMAT ? 'blocks' : 'unknown'
889
+ }
890
+
748
891
  function foldChanges(changes: SignedEvent[], rule: RecordsRule) {
749
- const fields: Record<string, { value: string; by: string; at: number }> = {}
892
+ const fields: Record<string, { value: string; by: string; at: number; format: ContentFormat }> = {}
750
893
  for (const change of [...changes].sort(byOrder)) {
751
894
  const field = tagValue(change, rule.fieldTag)
752
895
  const value = tagValue(change, rule.valueTag)
753
896
  if (!field || value === undefined) continue // a partial change sets nothing
754
- fields[field] = { value, by: change.pubkey, at: change.created_at }
897
+ // The format travels with the *winning* change, not with the object. SPEC
898
+ // §13.4: "an app MUST read the tag from the event it took the value from,
899
+ // never from the object's root" — a description created as marker text and
900
+ // later edited into blocks is a root with no tag and a change with one.
901
+ fields[field] = {
902
+ value,
903
+ by: change.pubkey,
904
+ at: change.created_at,
905
+ format: contentFormatOf(change),
906
+ }
755
907
  }
756
908
  return fields
757
909
  }
@@ -845,6 +997,19 @@ export interface ResolvedSlot {
845
997
  colour?: string
846
998
  /** True when the value is a pubkey and should be shown as a person. */
847
999
  isPubkey?: boolean
1000
+ /**
1001
+ * The content model this value is written in — SPEC §13.4.
1002
+ *
1003
+ * Set only for a slot whose source can carry a body (`{field: "content"}`, or
1004
+ * a `fold` a change event has actually set). **Undefined means the value is
1005
+ * not a body at all** — a tag, a seed, a default — not that it is marker
1006
+ * text. `'marker'` is what "a body with no declared format" resolves to, and
1007
+ * that is permanent rather than a migration state.
1008
+ *
1009
+ * A consumer rendering `slots.body` must branch on this. Parsing a block
1010
+ * document as marker text, or the reverse, is forbidden outright by §13.
1011
+ */
1012
+ format?: ContentFormat
848
1013
  /**
849
1014
  * The underlying field this slot reads, when it has a name.
850
1015
  *
@@ -879,21 +1044,79 @@ function firstTag(root: SignedEvent, tag: string | string[] | undefined): string
879
1044
  return undefined
880
1045
  }
881
1046
 
1047
+ /**
1048
+ * A slot's value, and the content model it is written in when it has one.
1049
+ *
1050
+ * `format` is set only where the value can be a **body**, which is true in two
1051
+ * places and no others:
1052
+ *
1053
+ * 1. `{field: "content"}` — an event's own body, content by definition. It gets
1054
+ * a format whatever slot it was declared into, which is what keeps the PRO-8
1055
+ * guard below reachable: `{"subtitle": {"field": "content", "truncate": 120}}`
1056
+ * is a mis-declaration, and it must not also escape the truncation rule.
1057
+ * 2. a slot named `body` — SPEC §7.2's closed set gives that name one meaning,
1058
+ * "structured content per §13". A `fold` is how §13.4's own example arrives
1059
+ * (a description created as marker text and later edited into blocks is a
1060
+ * root with no tag and a change with one), so it must be able to carry a
1061
+ * format — but only when the slot says it is a body.
1062
+ *
1063
+ * Everything else reports nothing. A tag is a short scalar and is never a body,
1064
+ * so a `title` reports no format even on an event whose *content* is a block
1065
+ * document; and a folded `lead` is a pubkey, not prose. Reporting `marker` for
1066
+ * those would be a claim about a value that is not content at all.
1067
+ */
1068
+ interface RawSlotValue {
1069
+ value: string
1070
+ format?: ContentFormat
1071
+ }
1072
+
882
1073
  function rawSlotValue(
883
1074
  spec: SlotSpec,
884
1075
  root: SignedEvent,
885
- folded: Record<string, { value: string }>,
886
- ): string | undefined {
1076
+ folded: Record<string, { value: string; format?: ContentFormat }>,
1077
+ isBody: boolean,
1078
+ ): RawSlotValue | undefined {
887
1079
  // `fold` first, and a spec may carry both: a field that starts as a tag on
888
1080
  // the root event and is then overridden by changes (a project's lead is the
889
1081
  // case in hand). Reading the tag first would render the value the object was
890
1082
  // created with forever — which is exactly what someone sees right after
891
1083
  // reassigning it from here.
892
1084
  if (spec.fold) {
893
- return folded[spec.fold]?.value ?? firstTag(root, spec.tag) ?? spec.default
1085
+ const change = folded[spec.fold]
1086
+ if (change) return { value: change.value, format: isBody ? (change.format ?? 'marker') : undefined }
1087
+ /*
1088
+ Nobody has changed this field, so the object's own creation value stands.
1089
+ §7.2 rule 2 covers seeding from a tag; `field: "content"` seeds from the
1090
+ event body, and the two compose — tag first, then content.
1091
+
1092
+ **Needed because a description is where an app puts its body and `content`
1093
+ is where the body goes.** Ship's issue description is
1094
+ `fields.description?.value ?? event.content` and its project description
1095
+ puts a `description` tag between the two (`fold.ts`), and neither could be
1096
+ declared before this. The nearest expressible declarations were both
1097
+ wrong in the way §7.2 rule 1 already warns about: `{field: "content"}`
1098
+ alone renders the value the object was created with for ever, and
1099
+ `{fold: "description"}` alone renders blank for every object nobody has
1100
+ edited — which is most of them, and blank reads as "that app is broken"
1101
+ (PEE-10).
1102
+
1103
+ The seed tag reports no format: a tag is a scalar. Content does, because
1104
+ it is the event's body whatever slot it was declared into — the same rule
1105
+ the direct `field: "content"` branch below follows, and what keeps the
1106
+ truncation guard reachable.
1107
+ */
1108
+ const seedTag = firstTag(root, spec.tag)
1109
+ if (seedTag !== undefined) return { value: seedTag }
1110
+ if (spec.field === 'content' && root.content !== '') {
1111
+ return { value: root.content, format: contentFormatOf(root) }
1112
+ }
1113
+ return spec.default === undefined ? undefined : { value: spec.default }
894
1114
  }
895
- if (spec.tag) return firstTag(root, spec.tag)
896
- if (spec.field === 'content') return root.content
1115
+ if (spec.tag) {
1116
+ const value = firstTag(root, spec.tag)
1117
+ return value === undefined ? undefined : { value }
1118
+ }
1119
+ if (spec.field === 'content') return { value: root.content, format: contentFormatOf(root) }
897
1120
  /*
898
1121
  `pubkey` — the event's author — added by PRO-6.
899
1122
 
@@ -908,21 +1131,37 @@ function rawSlotValue(
908
1131
  in a new source. Paired with `as: "pubkey"` it renders as a person, which is
909
1132
  what a message wants as its title everywhere it appears.
910
1133
  */
911
- if (spec.field === 'pubkey') return root.pubkey
1134
+ if (spec.field === 'pubkey') return { value: root.pubkey }
912
1135
  return undefined
913
1136
  }
914
1137
 
915
1138
  function resolveSlot(
916
1139
  spec: SlotSpec,
917
1140
  root: SignedEvent,
918
- folded: Record<string, { value: string }>,
1141
+ folded: Record<string, { value: string; format?: ContentFormat }>,
919
1142
  manifest: Manifest,
1143
+ name?: string,
920
1144
  ): ResolvedSlot | null {
921
- const raw = rawSlotValue(spec, root, folded)
1145
+ const source = rawSlotValue(spec, root, folded, name === BODY_SLOT)
1146
+
1147
+ if (source === undefined || source.value === '') return null
1148
+ const raw = source.value
922
1149
 
923
- if (raw === undefined || raw === '') return null
1150
+ /*
1151
+ PRO-8's rule, now enforceable rather than only written down.
1152
+
1153
+ `truncate` is a plain-text operation. Ship's manifest carried a comment
1154
+ saying so and a type that forbade the pairing, but nothing stopped another
1155
+ app publishing `{"field": "content", "truncate": 120}` — and the consumer
1156
+ would happily slice 120 characters out of a JSON block document and render
1157
+ the fragment. That is the PRO-8 defect exactly: output that is wrong and
1158
+ cannot tell that it is wrong.
924
1159
 
925
- let value = truncate(raw, spec.truncate)
1160
+ Marker text is still truncated. It degrades honestly — a cut `**bold` is
1161
+ visibly a cut, and 548 published messages are written in it.
1162
+ */
1163
+ const structured = source.format === 'blocks' || source.format === 'unknown'
1164
+ let value = structured ? raw : truncate(raw, spec.truncate)
926
1165
  let colour: string | undefined
927
1166
  if (spec.map) {
928
1167
  const entry = manifest.vocabularies?.[spec.map]?.find((v) => v.value === raw)
@@ -938,6 +1177,11 @@ function resolveSlot(
938
1177
  colour,
939
1178
  isPubkey: spec.as === 'pubkey',
940
1179
  field: spec.fold ?? (Array.isArray(spec.tag) ? spec.tag[0] : spec.tag),
1180
+ // Present only on a slot that can carry a body. Absent is the honest shape
1181
+ // for a title read from a tag — and it keeps the key out of every existing
1182
+ // consumer's deep comparisons, which is not the reason but is a real cost
1183
+ // avoided: `format: undefined` is an own property to `deepStrictEqual`.
1184
+ ...(source.format ? { format: source.format } : {}),
941
1185
  }
942
1186
  }
943
1187
 
@@ -951,7 +1195,7 @@ function resolveSlot(
951
1195
  function resolveSlots(
952
1196
  projection: { slots: Record<string, SlotSpec | SlotSpec[]> },
953
1197
  root: SignedEvent,
954
- folded: Record<string, { value: string }>,
1198
+ folded: Record<string, { value: string; format?: ContentFormat }>,
955
1199
  manifest: Manifest,
956
1200
  ): { slots: Record<string, ResolvedSlot>; meta: ResolvedSlot[] } {
957
1201
  const slots: Record<string, ResolvedSlot> = {}
@@ -959,11 +1203,13 @@ function resolveSlots(
959
1203
  for (const [name, spec] of Object.entries(projection.slots)) {
960
1204
  if (Array.isArray(spec)) {
961
1205
  for (const one of spec) {
1206
+ // An array spec collects into `meta`, so the name it was declared under
1207
+ // is not the slot's meaning — nothing in an array is a body.
962
1208
  const value = resolveSlot(one, root, folded, manifest)
963
1209
  if (value) meta.push(value)
964
1210
  }
965
1211
  } else {
966
- const value = resolveSlot(spec, root, folded, manifest)
1212
+ const value = resolveSlot(spec, root, folded, manifest, name)
967
1213
  if (value) slots[name] = value
968
1214
  }
969
1215
  }
@@ -1099,7 +1345,7 @@ function buildObject(args: {
1099
1345
  pointer: AddressPointer
1100
1346
  manifest: Manifest
1101
1347
  projection: { widget: string | string[]; slots: Record<string, SlotSpec | SlotSpec[]> }
1102
- folded: Record<string, { value: string }>
1348
+ folded: Record<string, { value: string; format?: ContentFormat }>
1103
1349
  viaRecommendation: boolean
1104
1350
  webTemplate?: string
1105
1351
  comments?: ForeignObject['comments']
@@ -1124,7 +1370,7 @@ function buildObject(args: {
1124
1370
  // *is* keyed on is the first, which is the one the app writes today. The
1125
1371
  // rest are only there to keep older records rendering.
1126
1372
  const field = spec.fold ?? (Array.isArray(spec.tag) ? spec.tag[0] : spec.tag)
1127
- const raw = field && rawSlotValue(spec, root, folded)
1373
+ const raw = field ? rawSlotValue(spec, root, folded, false)?.value : undefined
1128
1374
  if (field && raw !== undefined) held[field] = raw
1129
1375
  }
1130
1376
  const objectAddress = pointerToAddress(pointer)
@@ -2112,7 +2358,7 @@ export async function resolveFolderProject(
2112
2358
  const ticketChanges = changesFor(pointerToAddress(ticketPointer))
2113
2359
  const folded = foldChanges(ticketChanges, records)
2114
2360
  const status = statusSpec ? resolveSlot(statusSpec, event, folded, manifest) : null
2115
- const raw = statusSpec ? rawSlotValue(statusSpec, event, folded) : undefined
2361
+ const raw = statusSpec ? rawSlotValue(statusSpec, event, folded, false)?.value : undefined
2116
2362
 
2117
2363
  // The owning app's declaration first, the label guess only if it has none.
2118
2364
  const stage = declaredStage(manifest, statusSpec, raw)
@@ -2168,6 +2414,95 @@ export async function resolveFolderProject(
2168
2414
  * Returns a string on refusal rather than throwing — every failure here is
2169
2415
  * something a user should read.
2170
2416
  */
2417
+ /**
2418
+ * NIP-01's parameterized-replaceable range. An event in it is addressed by
2419
+ * `(kind, pubkey, d)`, so one created without a `d` has no address — it cannot
2420
+ * be referenced, commented on, or acted upon, and the owning app will not find
2421
+ * it where it looks.
2422
+ */
2423
+ const isAddressableKind = (kind: number) => kind >= 30000 && kind < 40000
2424
+
2425
+ /**
2426
+ * The event an object-creating action publishes.
2427
+ *
2428
+ * Split out because it shares almost nothing with a change: the tags come from
2429
+ * the form rather than from `records`, and the result is a new object rather
2430
+ * than a statement about an existing one.
2431
+ *
2432
+ * **Every refusal names what was allowed**, and that is the requirement rather
2433
+ * than a nicety. The owning app cannot enforce any of this — anyone can publish
2434
+ * anything — so a consumer that guesses is the one putting junk in a shared
2435
+ * record, and a consumer told only "invalid" cannot do better next time.
2436
+ */
2437
+ function buildCreationEvent(args: {
2438
+ declared: ManifestAction
2439
+ vocabularies?: Manifest['vocabularies']
2440
+ address: string
2441
+ folder: string
2442
+ value: string | Record<string, string>
2443
+ newId?: string
2444
+ pubkey: string
2445
+ createdAtMs: number
2446
+ }): UnsignedActionEvent | string {
2447
+ const { declared, address, folder, value, newId } = args
2448
+ const properties = declared.input!.properties!
2449
+ const required = declared.input!.required ?? []
2450
+
2451
+ if (typeof value === 'string') {
2452
+ return `"${declared.label}" takes a form: ${Object.keys(properties).join(', ')}.`
2453
+ }
2454
+
2455
+ const allowed = Object.keys(properties)
2456
+ for (const name of Object.keys(value)) {
2457
+ if (!allowed.includes(name)) {
2458
+ return `"${name}" is not a field of "${declared.label}" — it takes ${allowed.join(', ')}.`
2459
+ }
2460
+ }
2461
+ for (const name of required) {
2462
+ if (!value[name]?.trim()) return `"${name}" is required by "${declared.label}".`
2463
+ }
2464
+ /*
2465
+ A property may name a vocabulary of its own, checked exactly as a scalar
2466
+ action's is — the honour system does not get weaker because there are
2467
+ several fields. Ship declares none today (`add-issue` takes a bare title),
2468
+ so this is a path an app grows into rather than one in use.
2469
+ */
2470
+ for (const [name, held] of Object.entries(value)) {
2471
+ const vocabName = properties[name]?.enum
2472
+ if (!vocabName || !held) continue
2473
+ const vocab = args.vocabularies?.[vocabName] ?? []
2474
+ if (!vocab.some((entry) => entry.value === held)) {
2475
+ return `"${held}" is not one of ${vocab.map((e) => e.value).join(', ')}.`
2476
+ }
2477
+ }
2478
+
2479
+ if (isAddressableKind(declared.emits.kind) && !newId) {
2480
+ return `Creating a kind ${declared.emits.kind} needs an identifier, and none was supplied.`
2481
+ }
2482
+
2483
+ const tags: string[][] = []
2484
+ if (isAddressableKind(declared.emits.kind)) tags.push(['d', newId!])
2485
+ // A property's name is the tag it writes. See `ManifestAction.input`.
2486
+ for (const [name, held] of Object.entries(value)) {
2487
+ if (held !== '') tags.push([name, held])
2488
+ }
2489
+ // The parent. `toAddressOf: "self"` names the object the action was invoked
2490
+ // on; any other value is a shape nothing declares yet, and guessing at one
2491
+ // would publish a link the owning app never asked for.
2492
+ if (declared.emits.setTag && declared.emits.toAddressOf === 'self') {
2493
+ tags.push([declared.emits.setTag, address])
2494
+ }
2495
+ tags.push(['h', folder])
2496
+
2497
+ return {
2498
+ pubkey: args.pubkey,
2499
+ created_at: Math.floor(args.createdAtMs / 1000),
2500
+ kind: declared.emits.kind,
2501
+ tags,
2502
+ content: '',
2503
+ }
2504
+ }
2505
+
2171
2506
  export function buildActionEvent(args: {
2172
2507
  manifest: { records?: RecordsRule; actions?: ManifestAction[]; vocabularies?: Manifest['vocabularies'] }
2173
2508
  kind: number
@@ -2177,7 +2512,20 @@ export function buildActionEvent(args: {
2177
2512
  objectAuthor: string
2178
2513
  folder: string
2179
2514
  actionId: string
2180
- value: string
2515
+ /**
2516
+ * A scalar for a change or a comment; `{ property: value }` for an
2517
+ * object-creating action, whose form has several fields.
2518
+ */
2519
+ value: string | Record<string, string>
2520
+ /**
2521
+ * A fresh identifier for an object being created, when its kind is
2522
+ * parameterized-replaceable and therefore needs a `d`.
2523
+ *
2524
+ * Supplied rather than generated: ADR 0002 §10 constraint 2 — the runtime
2525
+ * reaches for nothing and is handed everything. It also makes the built event
2526
+ * a pure function of its inputs, which is what lets a test assert on one.
2527
+ */
2528
+ newId?: string
2181
2529
  pubkey: string
2182
2530
  createdAtMs: number
2183
2531
  }): UnsignedActionEvent | string {
@@ -2192,6 +2540,16 @@ export function buildActionEvent(args: {
2192
2540
  return `"${declared.label}" does not apply to a kind ${kind}.`
2193
2541
  }
2194
2542
 
2543
+ // An object-creating action is a different event entirely — a new object
2544
+ // rather than a change to one — so it branches before the scalar path.
2545
+ if (declared.input?.type === 'object' && declared.input.properties) {
2546
+ return buildCreationEvent({ ...args, declared, vocabularies: manifest.vocabularies })
2547
+ }
2548
+
2549
+ if (typeof value !== 'string') {
2550
+ return `"${declared.label}" takes a single value, not a form.`
2551
+ }
2552
+
2195
2553
  // Validate against the manifest's own vocabulary. The owning app cannot
2196
2554
  // enforce this — anyone can publish anything (RFC_UPDATES.md §3) — so a
2197
2555
  // consumer that skips the check is the one putting junk in the shared record.