@frontera-sdk/cli 1.43.6 → 1.43.7

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.
@@ -6,15 +6,27 @@ import {
6
6
  linkTypeCreateBody,
7
7
  linkTypeStructure,
8
8
  objectTypeCreateBody,
9
+ actionBody,
9
10
  metricCreateBody,
10
11
  metricPatchBody,
11
12
  readBacking,
13
+ resolveSemanticType,
12
14
  resolveSharedProperty,
15
+ semanticTypeCreateBody,
16
+ semanticTypePatchBody,
13
17
  sharedPropertyCreateBody,
14
18
  sharedPropertyPatchBody,
19
+ type AuthoringIds,
15
20
  } from '../../blueprint/compile'
16
21
  import { pickCurrentRevision } from '../../blueprint/dataset-revision'
17
- import { diff, isEmpty, satisfies, type ModelOperation, type Plan } from '../../blueprint/diff'
22
+ import {
23
+ assertPrunesLeaveNoDanglingReference,
24
+ diff,
25
+ isEmpty,
26
+ satisfies,
27
+ type ModelOperation,
28
+ type Plan,
29
+ } from '../../blueprint/diff'
18
30
  import {
19
31
  API_NAME_RULE,
20
32
  ARTIFACT_KINDS,
@@ -27,7 +39,12 @@ import {
27
39
  } from '../../blueprint/model'
28
40
  import { toFiles } from '../../blueprint/projection'
29
41
  import { renderDrift, renderPlan } from '../../blueprint/render'
30
- import { scaffold } from '../../blueprint/scaffold'
42
+ import {
43
+ SEMANTIC_TYPE_TEMPLATE_NAMES,
44
+ isSemanticTypeTemplate,
45
+ scaffold,
46
+ type SemanticTypeTemplate,
47
+ } from '../../blueprint/scaffold'
31
48
  import { pathFor, readTree, removeFromTree, writeTree } from '../../blueprint/tree'
32
49
  import { CliError } from '../../errors'
33
50
  import type { Command, CommandContext } from '../types'
@@ -244,7 +261,7 @@ async function computePlan(ctx: CommandContext): Promise<{
244
261
  }
245
262
  const files = readTree(directory)
246
263
  const { definition, revision } = await client.draft()
247
- const plan = diff(files, definition, revision)
264
+ const plan = withheldPrunes(diff(files, definition, revision), directory)
248
265
 
249
266
  // Per DISTINCT dataset, which is what the design promised and what the previous
250
267
  // loop did not do: it called `datasetRevisions` once per BOUND TYPE, after
@@ -276,6 +293,36 @@ async function computePlan(ctx: CommandContext): Promise<{
276
293
  return { plan, files, definition, drift }
277
294
  }
278
295
 
296
+ /**
297
+ * Withhold — and REPORT — prunes for a kind the tree has no directory for at all.
298
+ *
299
+ * `action` joining `ARTIFACT_KINDS` made every EXISTING tree suddenly claim authority
300
+ * over actions it had never modelled: such a tree has no `actions/`, so the first
301
+ * `apply --prune` after upgrading planned a delete for every live action in the
302
+ * organization — including ones authored through the Console or `blueprint create
303
+ * action`. Nothing writes the directory into an old tree, and nothing required a `pull`
304
+ * first, so an upgrade alone could delete work the tree had never seen.
305
+ *
306
+ * A missing directory is "this tree does not model that kind", which is not the same
307
+ * claim as an empty one. An empty `actions/` still prunes — that is a tree saying it
308
+ * models actions and has none.
309
+ */
310
+ function withheldPrunes(plan: Plan, directory: string): Plan {
311
+ const modelled = new Set(
312
+ ARTIFACT_KINDS.filter((kind) => existsSync(join(directory, KIND_DIRECTORY[kind]))),
313
+ )
314
+ const prunes = plan.prunes.filter((operation) => modelled.has(operation.kind))
315
+ if (prunes.length === plan.prunes.length) return plan
316
+ // Moved, not dropped: `renderPlan` prints these under their own heading so a tree
317
+ // that deleted a whole directory sees WHY nothing was pruned, instead of reading
318
+ // "Applied 0 changes" and concluding the artifacts were already gone.
319
+ return {
320
+ ...plan,
321
+ prunes,
322
+ withheld: plan.prunes.filter((operation) => !modelled.has(operation.kind)),
323
+ }
324
+ }
325
+
279
326
  export const blueprintPlan: Command = {
280
327
  meta: {
281
328
  noun: 'blueprint',
@@ -286,7 +333,16 @@ export const blueprintPlan: Command = {
286
333
  examples: ['frontera blueprint plan', 'frontera blueprint plan --json'],
287
334
  },
288
335
  async run(ctx) {
289
- const { plan, drift } = await computePlan(ctx)
336
+ const { plan, files, definition, drift } = await computePlan(ctx)
337
+ // Every refusal `apply` will raise, raised here too, and for the reason `plan`
338
+ // exists: a plan that cannot be applied is not one a reader should be shown as if
339
+ // it could. Two independent checks — a prune that would dangle a reference, and an
340
+ // ordering that has no solution.
341
+ orderForApply(plan.operations)
342
+ if (ctx.flags.prune === true) {
343
+ assertPrunesLeaveNoDanglingReference(files, plan.prunes)
344
+ orderForPrune(plan.prunes, liveCompensations(definition))
345
+ }
290
346
  const body = [renderPlan(plan, { prune: ctx.flags.prune === true }), renderDrift(drift)]
291
347
  .filter(Boolean)
292
348
  .join('\n')
@@ -297,24 +353,56 @@ export const blueprintPlan: Command = {
297
353
  /**
298
354
  * Apply one operation, on the route that carries its guard.
299
355
  *
356
+ * Exported for the same reason `assertApplicable` is: the tests drive this dispatch
357
+ * directly, which is the only way to reach the refusal an unknown kind gets.
358
+ *
300
359
  * Creations go to the per-artifact POST rather than to a whole-bundle write: the
301
360
  * create route mints every id, derives each property's base type from the pinned
302
361
  * column, and writes the `governance.sourceMappings` shell that a later `bind`
303
362
  * requires. An object type inserted any other way can never afterwards be bound.
304
363
  */
305
- async function applyOperation(
364
+ export async function applyOperation(
306
365
  client: BlueprintAuthoringApi,
307
366
  operation: ModelOperation,
308
367
  file: AuthoredFile,
309
368
  revision: number,
310
- sharedPropertyIds: Map<string, string>,
369
+ ids: AuthoringIds,
311
370
  ): Promise<number> {
312
371
  const resolveDataset = (name: string) => resolveRevision(client, name)
313
372
 
373
+ if (operation.kind === 'semantic-type') {
374
+ if (operation.operation === 'create') {
375
+ const created = await client.createSemanticType(
376
+ semanticTypeCreateBody(file, revision),
377
+ revision,
378
+ )
379
+ // Threaded exactly as a shared field's id is, and for the same run: a tree
380
+ // that introduces a semantic type and the field carrying it has no id at plan
381
+ // time, and both carriers resolve this map at the moment of their write.
382
+ const id = created?.semanticType?.id
383
+ if (!id) {
384
+ throw new CliError(
385
+ `Creating semantic type "${operation.apiName}" returned no id, so nothing can carry it.`,
386
+ {
387
+ code: 'FAILURE',
388
+ hint: 'The service is expected to answer with the created row. Re-run `frontera blueprint plan`.',
389
+ },
390
+ )
391
+ }
392
+ ids.semanticTypeIdByApiName.set(operation.apiName, id)
393
+ return nextRevision(created, revision)
394
+ }
395
+ assertApplicable('semantic-type', file, operation.liveDocument)
396
+ const updated = await client.updateSemanticType(
397
+ operation.apiName, semanticTypePatchBody(file, revision), revision,
398
+ )
399
+ return nextRevision(updated, revision)
400
+ }
401
+
314
402
  if (operation.kind === 'shared-field') {
315
403
  if (operation.operation === 'create') {
316
404
  const created = await client.createSharedProperty(
317
- sharedPropertyCreateBody(file, revision),
405
+ sharedPropertyCreateBody(file, revision, ids.semanticTypeIdByApiName),
318
406
  revision,
319
407
  )
320
408
  // The minted id, threaded into the property writes that follow in THIS run —
@@ -330,12 +418,14 @@ async function applyOperation(
330
418
  },
331
419
  )
332
420
  }
333
- sharedPropertyIds.set(operation.apiName, id)
421
+ ids.sharedPropertyIdByApiName.set(operation.apiName, id)
334
422
  return nextRevision(created, revision)
335
423
  }
336
424
  assertApplicable('shared-field', file, operation.liveDocument)
337
425
  const updated = await client.updateSharedProperty(
338
- operation.apiName, sharedPropertyPatchBody(file, revision), revision,
426
+ operation.apiName,
427
+ sharedPropertyPatchBody(file, revision, ids.semanticTypeIdByApiName),
428
+ revision,
339
429
  )
340
430
  return nextRevision(updated, revision)
341
431
  }
@@ -343,7 +433,7 @@ async function applyOperation(
343
433
  if (operation.kind === 'object-type') {
344
434
  if (operation.operation === 'create') {
345
435
  const created = await client.createObjectType(
346
- await objectTypeCreateBody(file, revision, resolveDataset, sharedPropertyIds),
436
+ await objectTypeCreateBody(file, revision, resolveDataset, ids),
347
437
  revision,
348
438
  )
349
439
  return nextRevision(created, revision)
@@ -368,7 +458,7 @@ async function applyOperation(
368
458
  }
369
459
  current = await applyObjectKeys(client, operation.apiName, file, operation.liveDocument, current)
370
460
  return applyPropertyPlan(
371
- client, operation.apiName, file, operation.liveDocument, current, sharedPropertyIds,
461
+ client, operation.apiName, file, operation.liveDocument, current, ids,
372
462
  )
373
463
  }
374
464
 
@@ -380,11 +470,52 @@ async function applyOperation(
380
470
  return nextRevision(body, revision)
381
471
  }
382
472
 
383
- if (operation.operation !== 'create') assertApplicable('metric', file, operation.liveDocument)
384
- const result = operation.operation === 'create'
385
- ? await client.createMetric(metricCreateBody(file, revision), revision)
386
- : await client.updateMetric(operation.apiName, metricPatchBody(file, revision), revision)
387
- return nextRevision(result, revision)
473
+ if (operation.kind === 'action') {
474
+ // Sent as authored, names and all. The service resolves the references against the
475
+ // draft and mints the ids the contract owns, so the file carries no uuid — see
476
+ // `action-reference-resolution` and `action-identity`. An update PRESERVES those
477
+ // ids by matching children on apiName, which is why re-applying an unchanged tree
478
+ // is a no-op rather than a churn of fresh identifiers.
479
+ const body = actionBody(file)
480
+ // Checked against what will actually be SENT, not against the file. `actionBody`
481
+ // drops a pasted `id`, and `id` is not applicable — so checking the file first
482
+ // refused every apply after the first with "id differs from the draft", naming a
483
+ // field the projection never writes and the author therefore cannot revert.
484
+ if (operation.operation !== 'create') {
485
+ assertApplicable('action', { ...file, document: body }, operation.liveDocument)
486
+ }
487
+ const result = operation.operation === 'create'
488
+ ? await client.createAction(body, revision)
489
+ : await client.updateAction(operation.apiName, body, revision)
490
+ return nextRevision(result, revision)
491
+ }
492
+
493
+ if (operation.kind === 'metric') {
494
+ if (operation.operation !== 'create') assertApplicable('metric', file, operation.liveDocument)
495
+ const result = operation.operation === 'create'
496
+ ? await client.createMetric(metricCreateBody(file, revision), revision)
497
+ : await client.updateMetric(operation.apiName, metricPatchBody(file, revision), revision)
498
+ return nextRevision(result, revision)
499
+ }
500
+
501
+ return assertNever(operation.kind, 'apply')
502
+ }
503
+
504
+ /**
505
+ * A kind this dispatch does not handle, refused loudly rather than fallen through.
506
+ *
507
+ * All three dispatches used to end in an UNCONDITIONAL metric arm, so a new kind was
508
+ * not a compile error and not a runtime error either: it was silently treated as a
509
+ * metric. `scaffold` wrote metric YAML under the new kind's path, `apply` POSTed it
510
+ * to `/metrics`, and `--prune` DELETED a metric that happened to share its apiName.
511
+ * The `never` parameter makes adding a kind a type error at each of the three, and
512
+ * this message is what a build that skipped the type check would say.
513
+ */
514
+ export function assertNever(kind: never, site: string): never {
515
+ throw new CliError(`"${String(kind)}" is not an artifact kind this ${site} can handle.`, {
516
+ code: 'USAGE',
517
+ hint: `Expected one of: ${ARTIFACT_KINDS.join(', ')}.`,
518
+ })
388
519
  }
389
520
 
390
521
  /**
@@ -431,9 +562,12 @@ function nextRevision(response: unknown, previous: number): number {
431
562
  * route refuses.
432
563
  */
433
564
  const APPLICABLE_FIELDS: Record<ArtifactKind, Set<string>> = {
565
+ 'semantic-type': new Set([
566
+ 'apiName', 'displayName', 'description', 'dataType', 'rule', 'status', 'visibility',
567
+ ]),
434
568
  'shared-field': new Set([
435
569
  'apiName', 'displayName', 'description', 'valueType', 'propertyType', 'formatConfig',
436
- 'status', 'visibility',
570
+ 'semanticType', 'status', 'visibility',
437
571
  ]),
438
572
  'object-type': new Set([
439
573
  'apiName', 'displayName', 'pluralDisplayName', 'description', 'icon', 'color',
@@ -441,6 +575,14 @@ const APPLICABLE_FIELDS: Record<ArtifactKind, Set<string>> = {
441
575
  ]),
442
576
  'link-type': new Set(['apiName', 'cardinality', 'description', 'from', 'to']),
443
577
  metric: new Set(['apiName', 'displayName', 'description', 'definition', 'formatConfig']),
578
+ // Every field, because the action PUT replaces the contract whole — there is no
579
+ // field an update cannot carry. Enumerated rather than left open so that a field the
580
+ // bundle gains later is refused here until someone confirms the route takes it,
581
+ // which is the same bargain the other kinds make.
582
+ action: new Set([
583
+ 'apiName', 'displayName', 'description', 'subject', 'effect', 'inputs',
584
+ 'businessOutcomes', 'concurrency', 'impact', 'submissionCriteria', 'governance',
585
+ ]),
444
586
  }
445
587
 
446
588
  export function assertApplicable(
@@ -634,6 +776,27 @@ function planProperties(
634
776
  const current = counterpart.sharedField ?? null
635
777
  if (wanted !== current) metadataPatch.sharedField = wanted
636
778
  }
779
+ // A semantic type is carried by NAME for the same reason and resolved at the same
780
+ // moment, and `null` is compared against a live absence for the same reason: a
781
+ // file that says `semanticType: null` against a field that carries none must plan
782
+ // nothing, or every run re-issues a detach the service refuses as a no-op.
783
+ //
784
+ // WHICH COMMAND IT RIDES is decided here rather than by the loop below, and the
785
+ // ordering is why. The executor sends every schema command before every metadata
786
+ // command, so a file that moves `valueType` and `semanticType` together would
787
+ // otherwise validate the new value type against the OLD semantic type and then
788
+ // the new semantic type against the already-changed value type — one of the two
789
+ // intermediate states contradicts, and the run fails halfway. Riding the same
790
+ // `update_field_schema` patch makes it one command, and R4.4's next-state
791
+ // validation judges the pair the command settles on. It is never on both.
792
+ if (property.semanticType !== undefined) {
793
+ const wanted = property.semanticType === null ? null : property.semanticType
794
+ const current = counterpart.semanticType ?? null
795
+ if (wanted !== current) {
796
+ if (Object.keys(schemaPatch).length > 0) schemaPatch.semanticType = wanted
797
+ else metadataPatch.semanticType = wanted
798
+ }
799
+ }
637
800
  const id = idByApiName.get(name)
638
801
  // A declared property with no live id is either brand new (handled above) or the
639
802
  // draft moved; skipping it quietly is how a change goes missing.
@@ -659,7 +822,7 @@ async function applyPropertyPlan(
659
822
  file: AuthoredFile,
660
823
  liveDocument: Record<string, unknown> | undefined,
661
824
  revision: number,
662
- sharedPropertyIds: Map<string, string>,
825
+ ids: AuthoringIds,
663
826
  ): Promise<number> {
664
827
  const detail = await client.getObjectTypeDetail(apiName)
665
828
  if (!detail?.id) {
@@ -723,7 +886,10 @@ async function applyPropertyPlan(
723
886
  // Resolved here rather than at plan time: a shared field created earlier in
724
887
  // this same run only entered the map when its create answered.
725
888
  const sharedPropertyId = resolveSharedProperty(
726
- file, name, property.sharedField, sharedPropertyIds,
889
+ file, name, property.sharedField, ids.sharedPropertyIdByApiName,
890
+ )
891
+ const semanticTypeId = resolveSemanticType(
892
+ file, `field "${name}"`, property.semanticType, ids.semanticTypeIdByApiName,
727
893
  )
728
894
  const result = await client.draftCommand({
729
895
  kind: 'add_field',
@@ -747,13 +913,19 @@ async function applyPropertyPlan(
747
913
  // is simply "no shared field" — `add_field`'s uuid is not nullable, and a
748
914
  // detach command on a field that is being created makes no sense anyway.
749
915
  ...(typeof sharedPropertyId === 'string' ? { sharedPropertyId } : {}),
916
+ // Same collapse: `add_field`'s uuid is not nullable, and a field being
917
+ // created has no meaning to detach from.
918
+ ...(typeof semanticTypeId === 'string' ? { semanticTypeId } : {}),
750
919
  },
751
920
  }, current)
752
921
  current = result.revision
753
922
  }
754
923
  for (const change of plan.schemaChanges) {
755
924
  const result = await client.draftCommand({
756
- kind: 'update_field_schema', objectId: detail.id, fieldId: change.id, patch: change.patch,
925
+ kind: 'update_field_schema',
926
+ objectId: detail.id,
927
+ fieldId: change.id,
928
+ patch: semanticTypeOnWire(file, change.apiName, change.patch, ids),
757
929
  }, current)
758
930
  current = result.revision
759
931
  }
@@ -761,16 +933,16 @@ async function applyPropertyPlan(
761
933
  // `sharedField` is the file's name for it and `sharedPropertyId` the wire's.
762
934
  // `null` survives the resolution and travels as `null`: that is the detach
763
935
  // signal on both sides, and dropping it left this patch empty.
764
- const { sharedField, ...patch } = change.patch
936
+ const { sharedField, ...rest } = change.patch
765
937
  const sharedPropertyId = resolveSharedProperty(
766
- file, change.apiName, sharedField, sharedPropertyIds,
938
+ file, change.apiName, sharedField, ids.sharedPropertyIdByApiName,
767
939
  )
768
940
  const result = await client.draftCommand({
769
941
  kind: 'update_field_metadata',
770
942
  objectId: detail.id,
771
943
  fieldId: change.id,
772
944
  patch: {
773
- ...patch,
945
+ ...semanticTypeOnWire(file, change.apiName, rest, ids),
774
946
  ...(sharedPropertyId === undefined ? {} : { sharedPropertyId }),
775
947
  },
776
948
  }, current)
@@ -785,7 +957,28 @@ async function applyPropertyPlan(
785
957
  return current
786
958
  }
787
959
 
788
- async function pruneOperation(
960
+ /**
961
+ * The file's `semanticType` name, replaced by the wire's `semanticTypeId`.
962
+ *
963
+ * ONE function for both commands, because both carry the reference: the planner
964
+ * decides which of the two a given change rides (see `planProperties`), and a
965
+ * translation written twice is how one of them keeps the authored key and reports a
966
+ * change it did not make. `null` survives — it is the detach signal on both sides.
967
+ */
968
+ function semanticTypeOnWire(
969
+ file: AuthoredFile,
970
+ propertyApiName: string,
971
+ patch: Record<string, unknown>,
972
+ ids: AuthoringIds,
973
+ ): Record<string, unknown> {
974
+ const { semanticType, ...rest } = patch
975
+ const semanticTypeId = resolveSemanticType(
976
+ file, `field "${propertyApiName}"`, semanticType, ids.semanticTypeIdByApiName,
977
+ )
978
+ return { ...rest, ...(semanticTypeId === undefined ? {} : { semanticTypeId }) }
979
+ }
980
+
981
+ export async function pruneOperation(
789
982
  client: BlueprintAuthoringApi,
790
983
  operation: ModelOperation,
791
984
  revision: number,
@@ -800,12 +993,23 @@ async function pruneOperation(
800
993
  }
801
994
  // Deleting a shared field DEGRADES its implementers rather than being refused:
802
995
  // each keeps the description and format it was inheriting. Deleting a label must
803
- // not delete the mappings underneath it.
996
+ // not delete the mappings underneath it. A semantic type degrades the same way —
997
+ // every carrier reverts to an unconstrained field, which is what a field without
998
+ // one has always been.
804
999
  const result = operation.kind === 'link-type'
805
1000
  ? await client.deleteLinkType(operation.apiName, revision)
806
- : operation.kind === 'shared-field'
807
- ? await client.deleteSharedProperty(operation.apiName, revision)
808
- : await client.deleteMetric(operation.apiName, revision)
1001
+ : operation.kind === 'action'
1002
+ ? await client.deleteAction(operation.apiName, revision)
1003
+ : operation.kind === 'shared-field'
1004
+ ? await client.deleteSharedProperty(operation.apiName, revision)
1005
+ : operation.kind === 'semantic-type'
1006
+ ? await client.deleteSemanticType(operation.apiName, revision)
1007
+ : operation.kind === 'metric'
1008
+ ? await client.deleteMetric(operation.apiName, revision)
1009
+ // Exhaustive on purpose — `assertNever` makes a kind added later a
1010
+ // TYPE error here rather than a silent fall-through to whichever
1011
+ // delete happened to be last.
1012
+ : assertNever(operation.kind, 'prune')
809
1013
  return nextRevision(result, revision)
810
1014
  }
811
1015
 
@@ -827,6 +1031,11 @@ export const blueprintApply: Command = {
827
1031
  const prune = ctx.flags.prune === true
828
1032
  const { plan, files, definition, drift } = await computePlan(ctx)
829
1033
 
1034
+ // BEFORE the first write, not before the prune loop: the operations land first,
1035
+ // so discovering the contradiction between them and the prunes at the end would
1036
+ // leave a half-applied run to unwind by hand.
1037
+ if (prune) assertPrunesLeaveNoDanglingReference(files, plan.prunes)
1038
+
830
1039
  if (isEmpty(plan)) {
831
1040
  return { data: { ...plan, applied: [] }, text: renderPlan(plan) }
832
1041
  }
@@ -846,6 +1055,19 @@ export const blueprintApply: Command = {
846
1055
  )
847
1056
  }
848
1057
 
1058
+ // BOTH orderings resolved before the confirmation, not inside the write loop.
1059
+ // Each refuses a mutual `compensationAction` pair, and a refusal that arrives
1060
+ // after the creates have committed leaves the draft half-applied — which is what
1061
+ // the prune side did, dying on a raw 409 from the service rather than on the
1062
+ // CliError the create side had already learned to raise.
1063
+ //
1064
+ // Creations before updates, and object types before the kinds that reference them:
1065
+ // a link names both of its endpoints, and a metric names the type it measures.
1066
+ // `ARTIFACT_KINDS` fixes that. Then one pass more, because actions can reference
1067
+ // EACH OTHER: a compensation target has to exist before the action naming it.
1068
+ const ordered = orderForApply(plan.operations)
1069
+ const orderedPrunes = prune ? orderForPrune(plan.prunes, liveCompensations(definition)) : []
1070
+
849
1071
  if (ctx.flags.yes !== true) {
850
1072
  throw new CliError(
851
1073
  `This would apply ${plan.operations.length} change${plan.operations.length === 1 ? '' : 's'}.\n`
@@ -857,24 +1079,25 @@ export const blueprintApply: Command = {
857
1079
  const byKey = new Map(files.map((file) => [`${file.kind}:${file.apiName}`, file]))
858
1080
  const applied: string[] = []
859
1081
  try {
860
- // Creations before updates, and object types before the kinds that reference
861
- // them: a link names both of its endpoints, and a metric names the type it
862
- // measures. `ARTIFACT_KINDS` fixes that order.
863
- const ordered = [...plan.operations].sort(byKindThenCreateFirst)
864
1082
  // Seeded from the revision the PLAN was computed against, then advanced by what
865
1083
  // each write returns. Never re-read — see `nextRevision`.
866
1084
  let revision = plan.revision
867
1085
  // Shared field apiName → id: what the draft already had, plus what this run
868
1086
  // creates. A file references a shared field by name and the wire takes an
869
1087
  // id, and one created in this run has no id until its create answers.
870
- const sharedPropertyIds = indexBundle(definition).sharedPropertyIdByApiName
1088
+ // …and the same for semantic types, which BOTH carrier kinds resolve.
1089
+ const live = indexBundle(definition)
1090
+ const ids: AuthoringIds = {
1091
+ sharedPropertyIdByApiName: live.sharedPropertyIdByApiName,
1092
+ semanticTypeIdByApiName: live.semanticTypeIdByApiName,
1093
+ }
871
1094
  for (const operation of ordered) {
872
1095
  const file = byKey.get(`${operation.kind}:${operation.apiName}`)!
873
- revision = await applyOperation(client, operation, file, revision, sharedPropertyIds)
1096
+ revision = await applyOperation(client, operation, file, revision, ids)
874
1097
  applied.push(`${operation.operation} ${operation.kind} ${operation.apiName}`)
875
1098
  }
876
1099
  if (prune) {
877
- for (const operation of plan.prunes) {
1100
+ for (const operation of orderedPrunes) {
878
1101
  revision = await pruneOperation(client, operation, revision)
879
1102
  applied.push(`delete ${operation.kind} ${operation.apiName}`)
880
1103
  }
@@ -907,6 +1130,195 @@ function byKindThenCreateFirst(left: ModelOperation, right: ModelOperation): num
907
1130
  return rank(left) - rank(right)
908
1131
  }
909
1132
 
1133
+ /**
1134
+ * Reorder action creates so a compensation target is created before the action naming
1135
+ * it.
1136
+ *
1137
+ * `effect.compensationAction` is the one reference an artifact can make to ANOTHER
1138
+ * ARTIFACT OF ITS OWN KIND, and the service resolves it against the draft — so on a
1139
+ * fresh deployment, creating `approveLoan` before `reverseApproval` is refused with
1140
+ * "names an action which is not on the draft". Alphabetical order decided which of the
1141
+ * two a tree got, which is to say it worked or not by accident of naming.
1142
+ *
1143
+ * Only creates are reordered. An update names a target that is already on the draft by
1144
+ * definition, and a plan that mixes the two still creates first — `byKindThenCreateFirst`
1145
+ * has already run.
1146
+ */
1147
+ export function orderForApply(operations: ModelOperation[]): ModelOperation[] {
1148
+ return byCompensationDependency([...operations].sort(byKindThenCreateFirst))
1149
+ }
1150
+
1151
+ /**
1152
+ * Live actions, each with the apiName of the action it compensates.
1153
+ *
1154
+ * The bundle stores `compensationActionId`, so the id is mapped back through the
1155
+ * action list — the prune preflight reasons in names, because that is what a prune
1156
+ * operation carries.
1157
+ */
1158
+ function liveCompensations(
1159
+ definition: DefinitionBundle,
1160
+ ): Array<{ apiName: string; compensates?: string }> {
1161
+ const actions = definition.actions ?? []
1162
+ const byId = new Map(actions.map((action) => [action.id, action.apiName]))
1163
+ return actions.map((action) => {
1164
+ const effect = (action as Record<string, unknown>).effect
1165
+ const id = effect && typeof effect === 'object'
1166
+ ? (effect as Record<string, unknown>).compensationActionId
1167
+ : undefined
1168
+ const compensates = typeof id === 'string' ? byId.get(id) : undefined
1169
+ return compensates === undefined ? { apiName: action.apiName } : { apiName: action.apiName, compensates }
1170
+ })
1171
+ }
1172
+
1173
+ /**
1174
+ * Deleting runs every dependency the other way round.
1175
+ *
1176
+ * REVERSE `ARTIFACT_KINDS`, because an action names an object type: pruning in creation
1177
+ * order removed the type out from under everything still pointing at it. And within the
1178
+ * action group, the MIRROR of `byCompensationDependency` — `deleteDraftBlueprintAction`
1179
+ * refuses with `ACTION_COMPENSATION_REFERENCE_CONFLICT` while any surviving action still
1180
+ * names the target, so a compensator has to go before the action it compensates.
1181
+ * Sorting by apiName alone meant renaming a pair decided whether the same tree pruned.
1182
+ */
1183
+ export function orderForPrune(
1184
+ prunes: ModelOperation[],
1185
+ liveActions: ReadonlyArray<{ apiName: string; compensates?: string }> = [],
1186
+ ): ModelOperation[] {
1187
+ const byKind = [...prunes].sort(
1188
+ (left, right) => ARTIFACT_KINDS.indexOf(right.kind) - ARTIFACT_KINDS.indexOf(left.kind),
1189
+ )
1190
+ const actions = byKind.filter((operation) => operation.kind === 'action')
1191
+ if (actions.length === 0) return byKind
1192
+
1193
+ const pruned = new Set(actions.map((operation) => operation.apiName))
1194
+
1195
+ // A SURVIVOR blocks the delete just as surely as a pruned pair does, and it is the
1196
+ // commoner case: `deleteDraftBlueprintAction` refuses while ANY remaining action
1197
+ // still names the target. Ordering among the prune set alone missed it entirely —
1198
+ // deleting one half of a pair planned clean and then died on a raw 409 mid-apply,
1199
+ // after the creates had committed, which is the half-applied outcome this pass
1200
+ // exists to prevent.
1201
+ const blocked = liveActions.filter((action) =>
1202
+ action.compensates !== undefined
1203
+ && pruned.has(action.compensates)
1204
+ && !pruned.has(action.apiName))
1205
+ if (blocked.length > 0) {
1206
+ const named = blocked
1207
+ .map((action) => `${action.apiName} → ${action.compensates}`)
1208
+ .sort()
1209
+ .join(', ')
1210
+ throw new CliError(
1211
+ `${blocked.length === 1 ? 'An action that survives this prune still names' : 'Actions that survive this prune still name'} `
1212
+ + `one being deleted as its compensation: ${named}.`,
1213
+ {
1214
+ code: 'USAGE',
1215
+ hint: 'Remove `compensationAction` from the surviving action and apply, then prune.',
1216
+ },
1217
+ )
1218
+ }
1219
+ if (actions.length < 2) return byKind
1220
+ // Reversed edge: `compensator → target` means the compensator is deleted FIRST, so
1221
+ // the target depends on it rather than the other way round.
1222
+ const compensatorsOf = new Map<string, ModelOperation[]>()
1223
+ for (const operation of actions) {
1224
+ const effect = operation.liveDocument?.effect
1225
+ const named = effect && typeof effect === 'object'
1226
+ ? (effect as Record<string, unknown>).compensationAction
1227
+ : undefined
1228
+ if (typeof named !== 'string' || !pruned.has(named)) continue
1229
+ const list = compensatorsOf.get(named) ?? []
1230
+ list.push(operation)
1231
+ compensatorsOf.set(named, list)
1232
+ }
1233
+
1234
+ const ordered: ModelOperation[] = []
1235
+ const placed = new Set<string>()
1236
+ const visiting = new Set<string>()
1237
+ const visit = (operation: ModelOperation): void => {
1238
+ if (placed.has(operation.apiName)) return
1239
+ // A mutual pair cannot be pruned by ordering alone, and it is refused HERE rather
1240
+ // than left to the service. There is a way through — drop the link, then prune —
1241
+ // but the service's 409 arrives mid-run, after every create and update in the same
1242
+ // apply has already committed. The create side pre-flights the mirror case; the
1243
+ // asymmetry was the defect, not the refusal.
1244
+ if (visiting.has(operation.apiName)) {
1245
+ throw new CliError(
1246
+ `${operation.apiName} and its compensation action name each other, so neither can be deleted first.`,
1247
+ {
1248
+ code: 'USAGE',
1249
+ hint: 'Remove `compensationAction` from one of them and apply, then prune both.',
1250
+ },
1251
+ )
1252
+ }
1253
+ visiting.add(operation.apiName)
1254
+ for (const compensator of compensatorsOf.get(operation.apiName) ?? []) visit(compensator)
1255
+ visiting.delete(operation.apiName)
1256
+ placed.add(operation.apiName)
1257
+ ordered.push(operation)
1258
+ }
1259
+ for (const operation of actions) visit(operation)
1260
+
1261
+ return [...ordered, ...byKind.filter((operation) => operation.kind !== 'action')]
1262
+ }
1263
+
1264
+ function byCompensationDependency(operations: ModelOperation[]): ModelOperation[] {
1265
+ const creates = new Map<string, ModelOperation>()
1266
+ for (const operation of operations) {
1267
+ if (operation.kind === 'action' && operation.operation === 'create') {
1268
+ creates.set(operation.apiName, operation)
1269
+ }
1270
+ }
1271
+ if (creates.size < 2) return operations
1272
+
1273
+ const target = (operation: ModelOperation): string | undefined => {
1274
+ const effect = operation.document?.effect
1275
+ if (!effect || typeof effect !== 'object') return undefined
1276
+ const named = (effect as Record<string, unknown>).compensationAction
1277
+ // Only a name that is ALSO being created here constrains the order. One that is
1278
+ // already on the draft resolves whenever this runs.
1279
+ return typeof named === 'string' && creates.has(named) ? named : undefined
1280
+ }
1281
+
1282
+ const ordered: ModelOperation[] = []
1283
+ const placed = new Set<string>()
1284
+ const visiting = new Set<string>()
1285
+ const visit = (operation: ModelOperation): void => {
1286
+ if (placed.has(operation.apiName)) return
1287
+ if (visiting.has(operation.apiName)) {
1288
+ // Two actions naming each other cannot both be created first, by either party.
1289
+ // Saying so beats a 409 from the service that names only one of the pair.
1290
+ throw new CliError(
1291
+ `${operation.apiName} and its compensation action name each other, so neither can be created first.`,
1292
+ {
1293
+ code: 'USAGE',
1294
+ hint: 'Apply one without `compensationAction`, then add the link in a second apply.',
1295
+ },
1296
+ )
1297
+ }
1298
+ visiting.add(operation.apiName)
1299
+ const dependency = target(operation)
1300
+ if (dependency) visit(creates.get(dependency)!)
1301
+ visiting.delete(operation.apiName)
1302
+ placed.add(operation.apiName)
1303
+ ordered.push(operation)
1304
+ }
1305
+
1306
+ // THREE buckets, not two. Collecting everything that is not an action create into
1307
+ // one `rest` and appending the creates after it put every action UPDATE ahead of
1308
+ // every action CREATE — inverting the create-first rule the incoming sort had just
1309
+ // established, and aborting a mixed plan after the earlier writes had committed.
1310
+ const before: ModelOperation[] = []
1311
+ const updates: ModelOperation[] = []
1312
+ for (const operation of operations) {
1313
+ if (operation.kind !== 'action') before.push(operation)
1314
+ else if (operation.operation === 'create') visit(operation)
1315
+ else updates.push(operation)
1316
+ }
1317
+ // Actions sort last in `ARTIFACT_KINDS`, so the other kinds keep their place ahead of
1318
+ // them; only the action creates are permuted, and only among themselves.
1319
+ return [...before, ...ordered, ...updates]
1320
+ }
1321
+
910
1322
  export const blueprintNew: Command = {
911
1323
  meta: {
912
1324
  noun: 'blueprint',
@@ -915,13 +1327,14 @@ export const blueprintNew: Command = {
915
1327
  { name: 'kind', required: true, description: `One of: ${ARTIFACT_KINDS.join(', ')}` },
916
1328
  { name: 'apiName', required: true, description: 'The artifact’s API name' },
917
1329
  ],
918
- flags: { dir: 'string' },
1330
+ flags: { dir: 'string', template: 'string' },
919
1331
  offline: true,
920
1332
  summary: 'Scaffold an artifact file that validates as written',
921
1333
  examples: [
922
1334
  'frontera blueprint new object-type Customer',
923
1335
  'frontera blueprint new link-type customerHoldsPolicy',
924
1336
  'frontera blueprint new shared-field accountCode',
1337
+ 'frontera blueprint new semantic-type completionRate --template percentage',
925
1338
  ],
926
1339
  },
927
1340
  async run(ctx) {
@@ -945,6 +1358,7 @@ export const blueprintNew: Command = {
945
1358
  hint: `${describeApiNameRule(kind)} — and it becomes the file name, so nothing else fits.`,
946
1359
  })
947
1360
  }
1361
+ const template = semanticTypeTemplate(ctx.flags.template, kind)
948
1362
  const directory = root(ctx)
949
1363
  const relative = pathFor(kind, apiName)
950
1364
  if (existsSync(resolve(directory, relative))) {
@@ -957,7 +1371,7 @@ export const blueprintNew: Command = {
957
1371
  kind,
958
1372
  apiName,
959
1373
  path: relative,
960
- document: scaffold(kind, apiName),
1374
+ document: scaffold(kind, apiName, template === undefined ? {} : { template }),
961
1375
  }
962
1376
  writeTree(directory, [file])
963
1377
  return {
@@ -967,6 +1381,35 @@ export const blueprintNew: Command = {
967
1381
  },
968
1382
  }
969
1383
 
1384
+ /**
1385
+ * `--template`, validated where it is read.
1386
+ *
1387
+ * Refused on the kinds that have none rather than ignored: a flag silently doing
1388
+ * nothing is how a reader concludes the scaffold ignored their choice and edits the
1389
+ * file by hand ever after.
1390
+ */
1391
+ function semanticTypeTemplate(
1392
+ declared: unknown,
1393
+ kind: ArtifactKind,
1394
+ ): SemanticTypeTemplate | undefined {
1395
+ if (declared === undefined) return undefined
1396
+ if (kind !== 'semantic-type') {
1397
+ throw new CliError(`--template applies to semantic-type, not ${kind}.`, {
1398
+ code: 'USAGE',
1399
+ hint: `frontera blueprint new semantic-type <apiName> --template ${SEMANTIC_TYPE_TEMPLATE_NAMES[0]}`,
1400
+ })
1401
+ }
1402
+ const name = String(declared)
1403
+ if (!isSemanticTypeTemplate(name)) {
1404
+ throw new CliError(`Unknown template "${name}".`, {
1405
+ code: 'USAGE',
1406
+ hint: `One of: ${SEMANTIC_TYPE_TEMPLATE_NAMES.join(', ')}. `
1407
+ + 'Each writes a rule the service accepts unedited.',
1408
+ })
1409
+ }
1410
+ return name
1411
+ }
1412
+
970
1413
  export const blueprintRename: Command = {
971
1414
  meta: {
972
1415
  noun: 'blueprint',