@frontera-sdk/cli 1.53.7 → 1.53.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +4 -4
  2. package/src/api/governed-action-api.ts +9 -3
  3. package/src/blueprint/action-rules.ts +175 -9
  4. package/src/blueprint/diff.ts +27 -8
  5. package/src/blueprint/ontology-edit-plan.ts +155 -4
  6. package/src/blueprint/org-name-check.ts +10 -8
  7. package/src/blueprint/projection.ts +13 -3
  8. package/src/blueprint/property-intents.ts +248 -0
  9. package/src/blueprint/publish-plan.ts +40 -4
  10. package/src/blueprint/render.ts +4 -0
  11. package/src/blueprint/tree.ts +22 -0
  12. package/src/commands/action/deploy.ts +31 -24
  13. package/src/commands/action/retire.ts +9 -1
  14. package/src/commands/action/submit.ts +21 -5
  15. package/src/commands/auth/login.ts +2 -2
  16. package/src/commands/blueprint/authoring.ts +22 -4
  17. package/src/commands/blueprint/declarative.ts +140 -32
  18. package/src/commands/knowledge/index-commands.ts +3 -4
  19. package/src/commands/local/check.ts +6 -4
  20. package/src/commands/local/index-commands.ts +8 -2
  21. package/src/commands/local/sync-request.ts +252 -66
  22. package/src/commands/local/sync.ts +29 -24
  23. package/src/device-login.ts +6 -1
  24. package/src/local/local-actions.ts +13 -1
  25. package/src/local/local-applied.ts +1 -1
  26. package/src/local/local-blueprint-schemas.ts +40 -6
  27. package/src/local/local-dev-references.ts +4 -3
  28. package/src/local/local-jobs.ts +28 -0
  29. package/src/local/local-portability.ts +1 -1
  30. package/src/local/local-sync-link.ts +62 -4
  31. package/src/local/local-sync-merge.ts +37 -1
  32. package/src/local/local-sync-plan.ts +44 -6
  33. package/src/local/local-sync-precheck.ts +2 -1
  34. package/src/local/local-sync-push.ts +22 -11
  35. package/src/local/local-sync-remote.ts +13 -8
  36. package/src/local/local-synthetic-data.ts +2 -2
  37. package/src/main.ts +7 -2
  38. package/src/vendor/sdk-sources.json +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.53.7",
3
+ "version": "1.53.9",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -40,8 +40,8 @@
40
40
  "dependencies": {
41
41
  "@anthropic-ai/claude-agent-sdk": "^0.3.251",
42
42
  "@faker-js/faker": "10.6.0",
43
- "@frontera-sdk/core": "1.53.7",
44
- "@frontera-sdk/functions": "1.53.7",
43
+ "@frontera-sdk/core": "1.53.9",
44
+ "@frontera-sdk/functions": "1.53.9",
45
45
  "ai": "^6.0.116",
46
46
  "axe-core": "4.12.1",
47
47
  "gray-matter": "^4.0.3",
@@ -49,7 +49,7 @@
49
49
  "yaml": "^2.9.0"
50
50
  },
51
51
  "devDependencies": {
52
- "@frontera-sdk/forge-contracts": "1.53.7",
52
+ "@frontera-sdk/forge-contracts": "1.53.9",
53
53
  "@types/bun": "^1.3.14",
54
54
  "typescript": "^5.9.3"
55
55
  }
@@ -600,10 +600,12 @@ export class GovernedActionApi {
600
600
  * A validation refusal's issues (`details.issues: [{ kind?, path, message }]`,
601
601
  * `kind` being `invocation_input` or `invocation` for a submission), one line
602
602
  * each as `path: message`, and a stale version's `details.currentVersion` as
603
- * the version to submit instead; empty when the details carry neither.
603
+ * the version to submit instead, in the record's own version input when the
604
+ * stale record is one the Action changes beside its subject (`details.record`);
605
+ * empty when the details carry neither.
604
606
  */
605
607
  export function issueLines(details: unknown): string[] {
606
- const d = (details && typeof details === 'object' ? details : {}) as { issues?: unknown; currentVersion?: unknown }
608
+ const d = (details && typeof details === 'object' ? details : {}) as { issues?: unknown; currentVersion?: unknown; record?: unknown; versionInput?: unknown }
607
609
  const lines: string[] = []
608
610
  if (Array.isArray(d.issues)) {
609
611
  for (const i of d.issues) {
@@ -615,7 +617,11 @@ export function issueLines(details: unknown): string[] {
615
617
  lines.push(where ? `${where}: ${message}` : message)
616
618
  }
617
619
  }
618
- if (typeof d.currentVersion === 'number' || typeof d.currentVersion === 'string') {
620
+ if ((typeof d.currentVersion === 'number' || typeof d.currentVersion === 'string') && typeof d.record === 'string') {
621
+ // A record the Action changes beside its subject, checked against its own version input.
622
+ const input = typeof d.versionInput === 'string' ? d.versionInput : 'its version input'
623
+ lines.push(`record ${d.record} is now at version ${String(d.currentVersion)}: read it again and submit with ${input} ${String(d.currentVersion)}`)
624
+ } else if (typeof d.currentVersion === 'number' || typeof d.currentVersion === 'string') {
619
625
  lines.push(`the record is now at version ${String(d.currentVersion)}: read it again and submit with expectedVersion ${String(d.currentVersion)}`)
620
626
  }
621
627
  return [...new Set(lines)].slice(0, 8)
@@ -272,6 +272,8 @@ interface ObjectTypeFacts {
272
272
  valueTypes?: ReadonlyMap<string, string>
273
273
  /** `backing.editable`: what a governed Action may write; null when the file declares none. */
274
274
  editable?: ReadonlySet<string> | null
275
+ /** The properties the type requires (`required: true`), which nothing may set to null. */
276
+ required?: ReadonlySet<string>
275
277
  }
276
278
 
277
279
  /** The input kinds that may set a property of each base type. */
@@ -295,6 +297,7 @@ export function actionRuleProblems(files: AuthoredFile[], inheritedConflict?: In
295
297
  primaryKey: typeof f.document.primaryKey === 'string' ? f.document.primaryKey : undefined,
296
298
  valueTypes: new Map(properties.flatMap((p) => (typeof p.apiName === 'string' && typeof p.valueType === 'string' ? [[p.apiName, p.valueType] as const] : []))),
297
299
  editable: Array.isArray(backing.editable) ? new Set(backing.editable.map(String)) : null,
300
+ required: new Set(properties.flatMap((p) => (p.required === true && typeof p.apiName === 'string' ? [p.apiName] : []))),
298
301
  })
299
302
  }
300
303
  const refused: string[] = []
@@ -324,8 +327,163 @@ export function actionRuleProblems(files: AuthoredFile[], inheritedConflict?: In
324
327
  return { refused, undeployable, refusedActions, ...(inherited.length ? { inherited, inheritedActions } : {}) }
325
328
  }
326
329
 
327
- /** One Action document: what the draft route refuses (`refused`) and what will not deploy (`undeployable`). */
330
+ /**
331
+ * One Action document: what the draft route refuses (`refused`) and what will not deploy (`undeployable`).
332
+ * An Action with `records` is checked as its subject, and then each record as the subject of a create
333
+ * (`actionFileForRecord`): a created record as the subject of a create, an existing one as the subject
334
+ * of an edit or delete, each addressed by its own key input and, when it exists, its own version input.
335
+ */
328
336
  export function checkAction(doc: Doc, types: ReadonlyMap<string, ObjectTypeFacts>, refused: string[], undeployable: string[]): void {
337
+ if (!Array.isArray(doc.records)) {
338
+ checkRecord(doc, types, refused, undeployable)
339
+ return
340
+ }
341
+ recordProblems(doc, types, refused, undeployable)
342
+ const subjectRefused: string[] = []
343
+ const subjectUndeployable: string[] = []
344
+ checkRecord(actionFileForRecord(doc, null), types, subjectRefused, subjectUndeployable)
345
+ refused.push(...subjectRefused)
346
+ undeployable.push(...subjectUndeployable)
347
+ for (const record of list(doc.records)) {
348
+ if (typeof record.key !== 'string') continue
349
+ const r: string[] = []
350
+ const u: string[] = []
351
+ checkRecord(actionFileForRecord(doc, record.key), types, r, u, {
352
+ keyInput: typeof record.keyInput === 'string' ? record.keyInput : undefined,
353
+ versionInput: typeof record.versionInput === 'string' ? record.versionInput : undefined,
354
+ })
355
+ refused.push(...recordOwnProblems(record.key, subjectRefused, r))
356
+ undeployable.push(...recordOwnProblems(record.key, subjectUndeployable, u))
357
+ }
358
+ }
359
+
360
+ /** A record's problems under its key. A problem with the file as a whole (an input's own shape) is reported once, by the subject. */
361
+ function recordOwnProblems(key: string, subjectProblems: readonly string[], problems: readonly string[]): string[] {
362
+ return problems.filter((problem) => !subjectProblems.includes(problem)).map((problem) => `records.${key}: ${problem}`)
363
+ }
364
+
365
+ /**
366
+ * The Action file as one record sees it: `null` is the subject, with only the changes that name no
367
+ * record; a record key is that record as a subject, with only its changes: a create for a record the
368
+ * Action creates, an update or delete under expected_version for one that exists. The service
369
+ * reviews each record the same way (`actionDefinitionForRecord`).
370
+ */
371
+ export function actionFileForRecord(doc: Doc, key: string | null): Doc {
372
+ const outcomes = list(doc.businessOutcomes)
373
+ const { records: _records, ...rest } = doc
374
+ // Another record's change stays as an empty placeholder, so every problem keeps the file's own path.
375
+ const changesOf = (o: Doc) => list(o.expectedChanges).map((c) => {
376
+ if ((c.record ?? null) !== key) return {}
377
+ const { record: _record, ...change } = c
378
+ return change
379
+ })
380
+ if (key === null) {
381
+ return { ...rest, businessOutcomes: outcomes.map((o) => ({ ...o, expectedChanges: changesOf(o) })) }
382
+ }
383
+ const record = list(doc.records).find((candidate) => candidate.key === key)
384
+ const existing = record?.mode === 'existing'
385
+ const deletes = outcomes.some((o) => o.effect === 'confirmed_applied' && list(o.expectedChanges).some((c) => c.record === key && c.kind === 'object_deleted'))
386
+ return {
387
+ ...rest,
388
+ subject: { objectType: record?.objectType, mode: existing ? 'existing' : 'create' },
389
+ effect: { ...(isRecord(doc.effect) ? doc.effect : {}), kind: existing ? (deletes ? 'delete' : 'update') : 'create' },
390
+ concurrency: { strategy: existing ? 'expected_version' : 'append_once' },
391
+ submissionCriteria: [],
392
+ businessOutcomes: outcomes.map((o) => ({ ...o, expectedChanges: changesOf(o), postconditions: [], output: [] })),
393
+ }
394
+ }
395
+
396
+ /**
397
+ * The rules on `records` themselves: each is named once and addressed by its own required, internal key
398
+ * input; a created record is created by this Action, and an existing one is checked against its own
399
+ * required version input and changed or deleted by it. One whose key or version input is the subject's
400
+ * key or version input saves, and its write path refuses it (`deriveOntologyEditPlan`).
401
+ */
402
+ function recordProblems(doc: Doc, types: ReadonlyMap<string, ObjectTypeFacts>, refused: string[], undeployable: string[]): void {
403
+ const records = list(doc.records)
404
+ const inputs = new Map(list(doc.inputs).flatMap((f) => (typeof f.apiName === 'string' ? [[f.apiName, f] as const] : [])))
405
+ const subjectType = isRecord(doc.subject) && typeof doc.subject.objectType === 'string' ? doc.subject.objectType : undefined
406
+ const subjectKey = subjectType ? types.get(subjectType)?.primaryKey : undefined
407
+ if (records.length > 10) refused.push(`records lists ${records.length} records; an Action writes at most 10 beside its subject`)
408
+ const keys = new Set<string>()
409
+ const keyInputs = new Set<string>()
410
+ const own = (where: string, role: string, name: string) => {
411
+ if (keyInputs.has(name)) refused.push(`${where} takes its ${role} from input ${name}, as another record does; give each record its own ${role} input`)
412
+ if (name === subjectKey || name === 'expectedVersion') undeployable.push(`${where} takes its ${role} from input ${name}, which already ${name === 'expectedVersion' ? 'carries the subject’s version' : 'addresses the subject'}; give the record its own ${role} input`)
413
+ keyInputs.add(name)
414
+ }
415
+ records.forEach((record, i) => {
416
+ const where = `records[${i}]`
417
+ if (typeof record.key !== 'string' || !/^[a-z][a-z0-9_]{0,99}$/.test(record.key)) refused.push(`${where} needs key: the record’s name in this Action, lowercase snake_case (key: event)`)
418
+ else if (keys.has(record.key)) refused.push(`${where}: two records are both named ${record.key}; give each its own key`)
419
+ else keys.add(record.key)
420
+ if (record.mode !== 'create' && record.mode !== 'existing') refused.push(`${where} needs mode: create (a record this Action creates) or existing (one it changes or deletes)`)
421
+ if (typeof record.objectType !== 'string') refused.push(`${where} needs objectType: the object type of the record, by apiName`)
422
+ else if (!types.has(record.objectType)) refused.push(`${where} names a ${record.objectType}, which no blueprint/object-types file defines`)
423
+ const keyInput = typeof record.keyInput === 'string' ? record.keyInput : undefined
424
+ if (!keyInput) refused.push(`${where} needs keyInput: the required input that carries the record’s primary key, by apiName`)
425
+ else {
426
+ const input = inputs.get(keyInput)
427
+ if (!input) refused.push(`${where} takes its key from input ${keyInput}, which the Action does not declare; add it under inputs (required: true)`)
428
+ else if (input.required !== true) refused.push(`${where} takes its key from input ${keyInput}, which must be required: true`)
429
+ else if (input.sensitivity !== 'internal') refused.push(`${where} takes its key from input ${keyInput}, which must be sensitivity: internal; a request names the records it writes wherever it is shown`)
430
+ own(where, 'key', keyInput)
431
+ }
432
+ if (record.mode !== 'existing') {
433
+ if (record.versionInput !== undefined) refused.push(`${where} is created by this Action, so it has no version to check; remove versionInput`)
434
+ return
435
+ }
436
+ const versionInput = typeof record.versionInput === 'string' ? record.versionInput : undefined
437
+ if (!versionInput) {
438
+ refused.push(`${where} needs versionInput: the required input that carries the version the request was decided on (a query row’s _meta.recordVersion), by apiName`)
439
+ return
440
+ }
441
+ const input = inputs.get(versionInput)
442
+ if (!input) refused.push(`${where} takes its version from input ${versionInput}, which the Action does not declare; add it under inputs ({ kind: number, integer: true, minimum: 0 }, required: true)`)
443
+ else if (input.required !== true) refused.push(`${where} takes its version from input ${versionInput}, which must be required: true`)
444
+ own(where, 'version', versionInput)
445
+ })
446
+ const byKey = new Map(records.flatMap((record) => (typeof record.key === 'string' ? [[record.key, record] as const] : [])))
447
+ const created = new Set<string>()
448
+ const changed = new Set<string>()
449
+ list(doc.businessOutcomes).forEach((o, i) => list(o.expectedChanges).forEach((c, j) => {
450
+ if (c.record === undefined) return
451
+ const where = `businessOutcomes[${i}].expectedChanges[${j}]`
452
+ const record = typeof c.record === 'string' ? byKey.get(c.record) : undefined
453
+ if (!record) {
454
+ refused.push(`${where} names record ${String(c.record)}, which records does not declare`)
455
+ return
456
+ }
457
+ if (c.objectType !== undefined && c.objectType !== record.objectType) refused.push(`${where} changes a ${String(c.objectType)}, but record ${String(c.record)} is a ${String(record.objectType)}`)
458
+ if (c.kind === 'lifecycle_transition') refused.push(`${where}: a lifecycle_transition changes the subject only, so it cannot name record ${String(c.record)}`)
459
+ else if (record.mode === 'existing' && c.kind === 'object_created') refused.push(`${where}: record ${String(c.record)} exists already, so object_created cannot name it; declare it with mode: create to create it`)
460
+ else if (record.mode !== 'existing' && c.kind === 'object_deleted') refused.push(`${where}: record ${String(c.record)} is created by this Action, so object_deleted cannot name it`)
461
+ if (o.effect !== 'confirmed_applied') return
462
+ if (c.kind === 'object_created') created.add(String(c.record))
463
+ else changed.add(String(c.record))
464
+ }))
465
+ for (const key of keys) {
466
+ const record = byKey.get(key)
467
+ const type = String(record?.objectType ?? '<ObjectType>')
468
+ if (record?.mode === 'existing') {
469
+ if (!changed.has(key)) refused.push(`records.${key} exists already and nothing changes it: add { kind: object_deleted, objectType: ${type}, record: ${key} } or a property_set with record: ${key} to its applied outcome`)
470
+ } else if (!created.has(key)) refused.push(`records.${key} is created by this Action: add { kind: object_created, objectType: ${type}, record: ${key} } to its applied outcome`)
471
+ }
472
+ }
473
+
474
+ function checkRecord(
475
+ doc: Doc,
476
+ types: ReadonlyMap<string, ObjectTypeFacts>,
477
+ refused: string[],
478
+ undeployable: string[],
479
+ /**
480
+ * A related record's key and version inputs; a subject's key input is named like its primary key,
481
+ * and its version input is expectedVersion.
482
+ */
483
+ record?: { keyInput?: string; versionInput?: string },
484
+ ): void {
485
+ const keyInput = record?.keyInput
486
+ const versionName = record?.versionInput ?? 'expectedVersion'
329
487
  const subject = isRecord(doc.subject) ? doc.subject : {}
330
488
  const effect = isRecord(doc.effect) ? doc.effect : {}
331
489
  const concurrency = isRecord(doc.concurrency) ? doc.concurrency : {}
@@ -403,8 +561,11 @@ export function checkAction(doc: Doc, types: ReadonlyMap<string, ObjectTypeFacts
403
561
  const kindOf = (f: Doc | undefined) => (f && isRecord(f.valueType) && typeof f.valueType.kind === 'string' ? f.valueType.kind : undefined)
404
562
 
405
563
  // The key: an edit or delete finds its record by it; a create files the new record under it.
406
- const key = pk ? byName.get(pk) : undefined
407
- if (pk && !key) {
564
+ const keyName = keyInput ?? pk
565
+ const key = keyName ? byName.get(keyName) : undefined
566
+ if (keyInput !== undefined) {
567
+ if (key && key.required !== true) undeployable.push(`inputs: "${keyInput}" is the record’s key, so it must be required: true`)
568
+ } else if (pk && !key) {
408
569
  undeployable.push(
409
570
  effectKind === 'create'
410
571
  ? `a create files the new record under its primary key, so it takes a required "${pk}" input (valueType: { kind: string, minLength: 1 }); add one`
@@ -417,7 +578,7 @@ export function checkAction(doc: Doc, types: ReadonlyMap<string, ObjectTypeFacts
417
578
  // compare and set on). Its value type is not a deploy rule: the version is checked as a whole
418
579
  // number from 0 when the Action is submitted, and the platform itself writes `{ kind: number }`
419
580
  // with `integer: false` and no minimum on Actions that deploy.
420
- if (!byName.get('expectedVersion')) undeployable.push('concurrency expected_version needs an "expectedVersion" input: { apiName: expectedVersion, valueType: { kind: number, integer: true, minimum: 0 }, required: true } — the record version the change was decided on (a query row’s _meta.recordVersion)')
581
+ if (record === undefined && !byName.get('expectedVersion')) undeployable.push('concurrency expected_version needs an "expectedVersion" input: { apiName: expectedVersion, valueType: { kind: number, integer: true, minimum: 0 }, required: true } — the record version the change was decided on (a query row’s _meta.recordVersion)')
421
582
  }
422
583
 
423
584
  const changes = outcomes.flatMap((o) => list(o.expectedChanges))
@@ -446,21 +607,26 @@ export function checkAction(doc: Doc, types: ReadonlyMap<string, ObjectTypeFacts
446
607
  }
447
608
  if (pk && st.property === pk) {
448
609
  if (st.value.kind === 'literal') undeployable.push(`${st.where} sets the primary key ${pk} to a constant; the key comes from its "${pk}" input, so remove this change`)
449
- else if (effectKind !== 'create' || st.value.field === pk) {
610
+ else if (effectKind !== 'create' || st.value.field === keyName) {
450
611
  undeployable.push(
451
612
  effectKind === 'create'
452
- ? `${st.where} sets the primary key ${pk}; a create already files the record under its "${pk}" input, so remove this change`
613
+ ? `${st.where} sets the primary key ${pk}; a create already files the record under its "${keyName}" input, so remove this change`
453
614
  : `${st.where} sets the primary key ${pk}, which addresses the record and cannot be changed; remove this change`,
454
615
  )
455
616
  }
456
617
  continue
457
618
  }
458
- if (st.value.kind === 'literal') continue
619
+ if (st.value.kind === 'literal') {
620
+ if (st.value.value === null && subjectType?.required?.has(st.property)) {
621
+ undeployable.push(`${st.where} sets ${st.property} to null, and ${typeName} requires it; set a value, or make ${st.property} optional (required: false)`)
622
+ }
623
+ continue
624
+ }
459
625
  if (st.value.kind !== 'input' || typeof st.value.field !== 'string') continue
460
626
  fromInput++
461
627
  if (byLiteral.has(st.property)) undeployable.push(`${st.where} sets ${st.property} from input ${st.value.field}, and another change sets it to a constant; keep one`)
462
- if (st.value.field === 'expectedVersion' && strategy === 'expected_version') {
463
- undeployable.push(`${st.where} sets ${st.property} from expectedVersion, which already carries the record version; give ${st.property} its own input`)
628
+ if (st.value.field === versionName && strategy === 'expected_version') {
629
+ undeployable.push(`${st.where} sets ${st.property} from ${versionName}, which already carries the record version; give ${st.property} its own input`)
464
630
  }
465
631
  // The input's kind fits the property's base type.
466
632
  const base = subjectType?.valueTypes?.get(st.property)
@@ -8,6 +8,7 @@ import {
8
8
  type DefinitionBundle,
9
9
  } from './model'
10
10
  import { CliError } from '../errors'
11
+ import type { PropertyIntents, PropertyRefusal } from './property-intents'
11
12
 
12
13
  /**
13
14
  * The plan: what the files say that the live draft does not, expressed as typed
@@ -37,6 +38,8 @@ export interface ModelOperation {
37
38
  * field is not merely wasteful, it fails the run.
38
39
  */
39
40
  liveDocument?: Record<string, unknown>
41
+ /** An object type's properties that keep their id and take a new name (`renamedFrom`), made before anything else on it. */
42
+ renames?: Array<{ from: string; to: string }>
40
43
  }
41
44
 
42
45
  export interface BindingDrift {
@@ -66,6 +69,8 @@ export interface Plan {
66
69
  editable: EditableChange[]
67
70
  /** Object types whose `backing.dataset` names another Dataset than the draft reads. */
68
71
  rebind: RebindChange[]
72
+ /** What the files' `renamedFrom` and `replaces` keys cannot be applied as (`property-intents.ts`). */
73
+ propertyRefusals?: PropertyRefusal[]
69
74
  unchanged: number
70
75
  }
71
76
 
@@ -270,13 +275,18 @@ function propertyNames(document: Record<string, unknown>): string[] {
270
275
  .filter((name): name is string => typeof name === 'string')
271
276
  }
272
277
 
273
- /** `+1 property (tier)` / `-1 property (legacyCode)`, or a bare field count. */
274
- function describeUpdate(local: Record<string, unknown>, live: Record<string, unknown>): string {
278
+ /** `+1 property (tier)` / `-1 property (legacyCode)`, a rename or a replacement said as one, or a bare field count. */
279
+ function describeUpdate(local: Record<string, unknown>, live: Record<string, unknown>, intents?: Pick<PropertyIntents, 'renames' | 'replacements'>, objectApiName?: string): string {
275
280
  const before = new Set(propertyNames(live))
276
281
  const after = propertyNames(local)
277
- const added = after.filter((name) => !before.has(name))
278
- const removed = [...before].filter((name) => !after.includes(name))
279
- const parts: string[] = []
282
+ const replacements = (intents?.replacements ?? []).filter((entry) => entry.objectApiName === objectApiName)
283
+ const added = after.filter((name) => !before.has(name) && !replacements.some((entry) => entry.added === name))
284
+ const removed = [...before].filter((name) => !after.includes(name) && !replacements.some((entry) => entry.removed === name))
285
+ const parts: string[] = [
286
+ ...(intents?.renames ?? []).filter((entry) => entry.objectApiName === objectApiName)
287
+ .map((entry) => `rename ${entry.from} → ${entry.to} (its values, edits and references stay)`),
288
+ ...replacements.map((entry) => `replace ${entry.removed} with ${entry.added} (${entry.removed}'s values are not carried over)`),
289
+ ]
280
290
  if (added.length) parts.push(`+${added.length} propert${added.length === 1 ? 'y' : 'ies'} (${added.join(', ')})`)
281
291
  if (removed.length) parts.push(`-${removed.length} propert${removed.length === 1 ? 'y' : 'ies'} (${removed.join(', ')})`)
282
292
  return parts.length ? parts.join(', ') : 'fields changed'
@@ -351,6 +361,8 @@ export function diff(
351
361
  revision: number,
352
362
  /** Absent when the Datasets were not read: no rebind is planned. */
353
363
  datasets?: DatasetIndex,
364
+ /** Read from the files first (`readPropertyIntents`): then `localFiles` and `live` are its own. */
365
+ intents?: Omit<PropertyIntents, 'files' | 'live'>,
354
366
  ): Plan {
355
367
  const liveModel = toFiles(live)
356
368
  const liveByKey = new Map(liveModel.files.map((file) => [`${file.kind}:${file.apiName}`, file]))
@@ -382,7 +394,10 @@ export function diff(
382
394
  })
383
395
  continue
384
396
  }
385
- if (matches(kind, file.document, existing.document)) {
397
+ const renames = kind === 'object-type'
398
+ ? (intents?.renames ?? []).filter((entry) => entry.objectApiName === file.apiName).map(({ from, to }) => ({ from, to }))
399
+ : []
400
+ if (matches(kind, file.document, existing.document) && renames.length === 0) {
386
401
  if (kind !== 'object-type' || !editableNames.has(file.apiName)) unchanged += 1
387
402
  continue
388
403
  }
@@ -392,7 +407,8 @@ export function diff(
392
407
  apiName: file.apiName,
393
408
  document: file.document,
394
409
  liveDocument: existing.document,
395
- detail: describeUpdate(file.document, existing.document),
410
+ detail: describeUpdate(file.document, existing.document, intents, file.apiName),
411
+ ...(renames.length ? { renames } : {}),
396
412
  })
397
413
  }
398
414
  for (const file of liveModel.files.filter((candidate) => candidate.kind === kind)) {
@@ -406,7 +422,10 @@ export function diff(
406
422
  }
407
423
  }
408
424
 
409
- return { revision, operations, prunes, drift: driftOf(live), editable, rebind, unchanged }
425
+ return {
426
+ revision, operations, prunes, drift: driftOf(live), editable, rebind, unchanged,
427
+ ...(intents?.refusals.length ? { propertyRefusals: intents.refusals } : {}),
428
+ }
410
429
  }
411
430
 
412
431
  /**
@@ -25,6 +25,11 @@ export interface PublishedActionDefinition {
25
25
  businessOutcomes: Array<{
26
26
  expectedChanges?: Array<Record<string, unknown>>
27
27
  }>
28
+ /** Records the Action writes beside its subject; each becomes a related step of the plan. */
29
+ records?: Array<
30
+ | { key: string; objectTypeId: string; mode: 'create'; keyFieldId: string }
31
+ | { key: string; objectTypeId: string; mode: 'existing'; keyFieldId: string; versionFieldId: string }
32
+ >
28
33
  }
29
34
 
30
35
  export interface ObjectTypeShape {
@@ -63,6 +68,16 @@ export interface OntologyEditPlan {
63
68
  mediaAttachment?: { propertyApiName?: string; itemIdProperty?: string; mediaSetId: string; uploadParameter: string }
64
69
  impactBounds: { maxAffectedRows: number; maxTouchedRows: number }
65
70
  capabilities: Record<string, unknown>
71
+ related?: Array<{
72
+ record: string
73
+ objectTypeId: string
74
+ objectTypeApiName: string
75
+ op: 'create' | 'patch' | 'delete'
76
+ set: Record<string, string>
77
+ pkParameter: string
78
+ /** Present exactly when the record exists already. */
79
+ expectedVersionParameter?: string
80
+ }>
66
81
  }
67
82
 
68
83
  export class PlanDerivationError extends Error {
@@ -94,6 +109,46 @@ export function ontologyEditCompatibilityFingerprint(
94
109
  return `sha256:${createHash('sha256').update(canonical).digest('hex')}`
95
110
  }
96
111
 
112
+ /**
113
+ * The service's `ontologyEditPlanFingerprint`, reproduced: the subject's
114
+ * fingerprint alone, or for a plan with related records, one over the subject's
115
+ * and each record's (canonical JSON, keys in lexicographic order).
116
+ */
117
+ export function ontologyEditPlanFingerprint(plan: Pick<OntologyEditPlan, 'targetCompatibility' | 'related'>): string {
118
+ const subject = plan.targetCompatibility.expectedFingerprint
119
+ if (!plan.related?.length) return subject
120
+ const related = plan.related
121
+ .map((step) => ({ fingerprint: ontologyEditCompatibilityFingerprint(step.objectTypeId, Object.keys(step.set)), record: step.record }))
122
+ .sort((a, b) => (a.record < b.record ? -1 : a.record > b.record ? 1 : 0))
123
+ return `sha256:${createHash('sha256').update(JSON.stringify({ related, subject })).digest('hex')}`
124
+ }
125
+
126
+ /** The Action as one record sees it: the service's `actionDefinitionForRecord`. */
127
+ function actionForRecord(
128
+ action: PublishedActionDefinition,
129
+ record: NonNullable<PublishedActionDefinition['records']>[number] | null,
130
+ ): PublishedActionDefinition {
131
+ const changesOf = (outcome: PublishedActionDefinition['businessOutcomes'][number]) =>
132
+ (outcome.expectedChanges ?? [])
133
+ .filter((change) => change.record === record?.key)
134
+ .map(({ record: _record, ...change }) => change)
135
+ if (record === null) {
136
+ return { ...action, businessOutcomes: action.businessOutcomes.map((outcome) => ({ ...outcome, expectedChanges: changesOf(outcome) })) }
137
+ }
138
+ const businessOutcomes = action.businessOutcomes.map((outcome) => ({ ...outcome, expectedChanges: changesOf(outcome) }))
139
+ if (record.mode === 'create') {
140
+ return { ...action, subject: { objectTypeId: record.objectTypeId, mode: 'create' }, effect: { kind: 'create' }, concurrency: { strategy: 'append_once' }, businessOutcomes }
141
+ }
142
+ const deletes = businessOutcomes.some((outcome) => outcome.expectedChanges.some((change) => change.kind === 'object_deleted'))
143
+ return {
144
+ ...action,
145
+ subject: { objectTypeId: record.objectTypeId, mode: 'existing' },
146
+ effect: { kind: deletes ? 'delete' : 'update' },
147
+ concurrency: { strategy: 'expected_version' },
148
+ businessOutcomes,
149
+ }
150
+ }
151
+
97
152
  const OP_FOR_EFFECT: Readonly<Record<string, OntologyEditPlan['op']>> = {
98
153
  update: 'patch',
99
154
  create: 'create',
@@ -104,6 +159,102 @@ export function deriveOntologyEditPlan(
104
159
  action: PublishedActionDefinition,
105
160
  objectType: ObjectTypeShape,
106
161
  attachment?: MediaAttachmentChoice,
162
+ /** The object types of the records the Action writes beside its subject. */
163
+ relatedTypes: readonly ObjectTypeShape[] = [],
164
+ ): OntologyEditPlan {
165
+ if (!action.records?.length) return deriveStep(action, objectType, attachment)
166
+ const subject = deriveStep(actionForRecord(action, null), objectType, attachment)
167
+ const inputName = (id: string) => action.inputs.find((input) => input.id === id)?.apiName
168
+ // Every version parameter of the edit: none may also be written as a value.
169
+ const versions = [
170
+ ...(subject.expectedVersionParameter ? [subject.expectedVersionParameter] : []),
171
+ ...action.records.flatMap((record) => (record.mode === 'existing' ? [inputName(record.versionFieldId)].filter((name): name is string => name !== undefined) : [])),
172
+ ]
173
+ const related = action.records.map((record) => {
174
+ const type = relatedTypes.find((candidate) => candidate.id === record.objectTypeId)
175
+ if (!type) {
176
+ throw new PlanDerivationError(
177
+ `Action "${action.apiName}" writes record "${record.key}" of an object type this deploy did not find.`,
178
+ 'Publish the object type the record names, then deploy again.',
179
+ )
180
+ }
181
+ const keyParameter = action.inputs.find((input) => input.id === record.keyFieldId)?.apiName
182
+ if (!keyParameter) {
183
+ throw new PlanDerivationError(
184
+ `Action "${action.apiName}" files record "${record.key}" under an input it does not declare.`,
185
+ 'Name a required input of the Action as the record’s keyInput.',
186
+ )
187
+ }
188
+ if (keyParameter === subject.pkParameter || keyParameter === subject.expectedVersionParameter) {
189
+ throw new PlanDerivationError(
190
+ `Action "${action.apiName}" files record "${record.key}" under ${keyParameter}, which already ${keyParameter === subject.pkParameter ? 'addresses the subject' : 'carries the subject’s version'}.`,
191
+ 'Give the record its own required key input.',
192
+ )
193
+ }
194
+ const versionParameter = record.mode === 'existing' ? inputName(record.versionFieldId) : undefined
195
+ if (record.mode === 'existing' && !versionParameter) {
196
+ throw new PlanDerivationError(
197
+ `Action "${action.apiName}" checks record "${record.key}" against a version input it does not declare.`,
198
+ 'Name a required input of the Action as the record’s versionInput.',
199
+ )
200
+ }
201
+ if (versionParameter && (versionParameter === subject.pkParameter || versionParameter === subject.expectedVersionParameter)) {
202
+ throw new PlanDerivationError(
203
+ `Action "${action.apiName}" checks record "${record.key}" against ${versionParameter}, which already ${versionParameter === subject.pkParameter ? 'addresses the subject' : 'carries the subject’s version'}.`,
204
+ 'Give the record its own required version input.',
205
+ )
206
+ }
207
+ let step: OntologyEditPlan
208
+ try {
209
+ step = deriveStep(actionForRecord(action, record), type, undefined, keyParameter, versionParameter)
210
+ } catch (error) {
211
+ if (!(error instanceof PlanDerivationError)) throw error
212
+ throw new PlanDerivationError(`Record "${record.key}": ${error.message}`, error.hint)
213
+ }
214
+ const fromVersion = Object.values(step.set).find((parameter) => versions.includes(parameter))
215
+ if (fromVersion) {
216
+ throw new PlanDerivationError(
217
+ `Record "${record.key}" writes a property from ${fromVersion}, which carries a record’s version.`,
218
+ 'Give that property its own input: every mapped parameter carries exactly one role.',
219
+ )
220
+ }
221
+ return {
222
+ record: record.key,
223
+ objectTypeId: step.objectTypeId,
224
+ objectTypeApiName: step.objectTypeApiName,
225
+ op: step.op,
226
+ set: step.set,
227
+ pkParameter: step.pkParameter,
228
+ ...(step.expectedVersionParameter ? { expectedVersionParameter: step.expectedVersionParameter } : {}),
229
+ }
230
+ })
231
+ const addresses = related.flatMap((step) => [step.pkParameter, ...(step.expectedVersionParameter ? [step.expectedVersionParameter] : [])])
232
+ const reused = addresses.find((parameter, index) => addresses.indexOf(parameter) !== index)
233
+ if (reused) {
234
+ throw new PlanDerivationError(
235
+ `Action "${action.apiName}" addresses two records with the same input, ${reused}.`,
236
+ 'Give each record its own required key input and, when it exists, its own version input.',
237
+ )
238
+ }
239
+ const rows = 1 + related.length
240
+ return {
241
+ ...subject,
242
+ targetCompatibility: { kind: 'ontology_edit', expectedFingerprint: ontologyEditPlanFingerprint({ ...subject, related }) },
243
+ impactBounds: { maxAffectedRows: rows, maxTouchedRows: rows },
244
+ related,
245
+ }
246
+ }
247
+
248
+ /**
249
+ * One record's step: the subject's, or a related record's addressed by `keyParameter` and, when it
250
+ * exists, checked against `versionParameter`.
251
+ */
252
+ function deriveStep(
253
+ action: PublishedActionDefinition,
254
+ objectType: ObjectTypeShape,
255
+ attachment?: MediaAttachmentChoice,
256
+ keyParameter?: string,
257
+ versionParameter?: string,
107
258
  ): OntologyEditPlan {
108
259
  const op = OP_FOR_EFFECT[action.effect.kind]
109
260
  if (!op) {
@@ -134,7 +285,7 @@ export function deriveOntologyEditPlan(
134
285
  const constants: string[] = []
135
286
  for (const outcome of action.businessOutcomes) {
136
287
  for (const change of outcome.expectedChanges ?? []) {
137
- if (change.kind !== 'property_set') continue
288
+ if (change.kind !== 'property_set' || change.record !== undefined) continue
138
289
  const value = change.value as { kind?: string; fieldId?: string } | undefined
139
290
  if (value?.kind === 'literal') {
140
291
  const property = propertyApiNameById.get(String(change.propertyId))
@@ -177,7 +328,7 @@ export function deriveOntologyEditPlan(
177
328
  'Set one with `frontera blueprint update object-type`, then publish.',
178
329
  )
179
330
  }
180
- const pkParameter = action.inputs.find((i) => i.apiName === pkApiName)?.apiName
331
+ const pkParameter = keyParameter ?? action.inputs.find((i) => i.apiName === pkApiName)?.apiName
181
332
  if (!pkParameter) {
182
333
  throw new PlanDerivationError(
183
334
  `Action "${action.apiName}" takes no "${pkApiName}" parameter, so an edit has no address.`,
@@ -213,7 +364,7 @@ export function deriveOntologyEditPlan(
213
364
  // Only when the Action asked for it. Compare-and-set is the Action's
214
365
  // concurrency contract, not a deployment preference.
215
366
  const expectedVersionParameter = action.concurrency.strategy === 'expected_version'
216
- ? action.inputs.find((i) => i.apiName === 'expectedVersion')?.apiName
367
+ ? versionParameter ?? action.inputs.find((i) => i.apiName === 'expectedVersion')?.apiName
217
368
  : undefined
218
369
  if (action.concurrency.strategy === 'expected_version' && !expectedVersionParameter) {
219
370
  throw new PlanDerivationError(
@@ -229,7 +380,7 @@ export function deriveOntologyEditPlan(
229
380
  }
230
381
  if (expectedVersionParameter && Object.values(set).includes(expectedVersionParameter)) {
231
382
  throw new PlanDerivationError(
232
- `Action "${action.apiName}" writes a property from expectedVersion, which carries the record version.`,
383
+ `Action "${action.apiName}" writes a property from ${expectedVersionParameter}, which carries the record version.`,
233
384
  'Give that property its own input: every mapped parameter carries exactly one role.',
234
385
  )
235
386
  }
@@ -1,6 +1,7 @@
1
1
  import { localProject } from '../local/local-project'
2
- import { syncClient, unwrap, type SyncClient } from '../local/local-sync-client'
3
- import { getSyncKey, readLink } from '../local/local-sync-link'
2
+ import { signInCommand } from '../device-login'
3
+ import { unwrap, type SyncClient } from '../local/local-sync-client'
4
+ import { organizationReader, readLink, targetProfileName } from '../local/local-sync-link'
4
5
  import type { ArtifactKind } from './model'
5
6
 
6
7
  /**
@@ -8,9 +9,9 @@ import type { ArtifactKind } from './model'
8
9
  * (Forge improvement plan X11). A workspace sees only its slice of the
9
10
  * organization's Blueprint, so `blueprint new object-type Activity` wrote a
10
11
  * file a change request then refused: the organization had an `Activity`
11
- * outside the slice (P5 A9). Read with the project's organization key from
12
- * `frontera local sync connect`; without a link or that key it is not
13
- * checked, and says so.
12
+ * outside the slice (P5 A9). Read as the person signed in for the project's
13
+ * workspace, or with its organization key; without a link or either it is
14
+ * not checked, and says so.
14
15
  */
15
16
  export type NameCheck =
16
17
  | { checked: false; why: string }
@@ -39,9 +40,10 @@ export async function orgNameCheck(root: string, kind: ArtifactKind, apiName: st
39
40
  const project = localProject(root)
40
41
  const link = readLink(project.root)
41
42
  if (!link) return { checked: false, why: 'the project is linked to no workspace' }
42
- const key = getSyncKey(project, 'organization')
43
- if (!key) return { checked: false, why: 'the project has no organization key (`frontera local sync connect --org-key -`)' }
44
- client = syncClient(link.platform, key)
43
+ const reader = await organizationReader(project, link)
44
+ if (reader.notSteward) return { checked: false, why: `${reader.notSteward}, signed in for the project's workspace, is not a Blueprint Steward, who reads the organization's names` }
45
+ if (!reader.client) return { checked: false, why: `no one is signed in for the project's workspace (\`${signInCommand(targetProfileName(project, link))}\`)` }
46
+ client = reader.client
45
47
  }
46
48
  try {
47
49
  const found = await client.find<unknown>('/v1/blueprint/lifecycle')