rcf-lite 0.30.0 → 0.31.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/fixtures/canary-manifest.json +9 -9
  3. package/guidance/define-draft-shapes.md +15 -10
  4. package/package.json +1 -1
  5. package/rcf/adrs/adr-4131-gates-bite-ack-recorded-override.json +3 -3
  6. package/rcf/adrs/adr-4137-standalone-theme-persistence.json +30 -0
  7. package/rcf/adrs/adr-4138-tree-wide-checks-before-scope-shortcircuit.json +35 -0
  8. package/rcf/code-nodes/cn-771.json +14 -0
  9. package/rcf/code-nodes/cn-772.json +14 -0
  10. package/rcf/code-nodes/cn-773.json +16 -0
  11. package/rcf/code-nodes/cn-774.json +20 -0
  12. package/rcf/code-nodes/cn-775.json +15 -0
  13. package/rcf/code-nodes/cn-776.json +17 -0
  14. package/rcf/code-nodes/cn-777.json +21 -0
  15. package/rcf/fbs/fbs-202.json +18 -2
  16. package/rcf/fbs/fbs-203.json +12 -4
  17. package/rcf/requirements/req-002.json +2 -2
  18. package/rcf/requirements/req-173.json +2 -2
  19. package/rcf/requirements/req-174.json +2 -2
  20. package/rcf/tacs/tac-4122-define-gates.json +7 -2
  21. package/rcf/test-suites/ts-007.json +1 -1
  22. package/rcf/test-suites/ts-215.json +64 -1
  23. package/rcf/test-suites/ts-216.json +18 -2
  24. package/rcf/test-suites/ts-219.json +10 -2
  25. package/rcf/test-suites/ts-220.json +18 -2
  26. package/rcf/test-suites/ts-225.json +10 -2
  27. package/rcf/test-suites/ts-229.json +49 -3
  28. package/rcf/test-suites/ts-230.json +26 -2
  29. package/rcf/test-suites/ts-231.json +16 -1
  30. package/rcf/test-suites/ts-236.json +44 -0
  31. package/rcf/user-stories/us-17302.json +36 -1
  32. package/rcf/user-stories/us-17401.json +11 -1
  33. package/rcf/user-stories/us-17402.json +6 -1
  34. package/rcf/user-stories/us-17403.json +11 -1
  35. package/rcf/user-stories/us-17404.json +16 -1
  36. package/rcf/user-stories/us-17405.json +7 -2
  37. package/rcf/user-stories/us-205.json +6 -1
  38. package/rcf/user-stories/us-206.json +25 -4
  39. package/rcf/user-stories/us-207.json +36 -0
  40. package/releases/releases.yaml +11 -1
  41. package/src/cli/create.js +22 -2
  42. package/src/cli/ledger.js +96 -4
  43. package/src/cli/readiness.js +15 -1
  44. package/src/core/errors/index.js +7 -1
  45. package/src/core/store/writer.js +181 -13
  46. package/src/define/ledgers.js +86 -7
  47. package/src/mcp/tools.js +1 -1
  48. package/src/query/gates.js +383 -150
  49. package/src/view/html-page.js +14 -2
  50. package/src/view/page-init.js +149 -8
  51. package/src/view/style.css +97 -18
  52. package/src/view/view-index.js +22 -0
@@ -52,6 +52,111 @@ function canonicalKind(kind) {
52
52
  return KIND_ALIASES[kind] ?? kind;
53
53
  }
54
54
 
55
+ /**
56
+ * Fields the R1 create-replay compare ignores: every field the writer or
57
+ * a post-create side effect stamps on its own, never from the caller's
58
+ * input. A verbatim replay of a create must compare equal even after
59
+ * those stamps landed; every other field is deep-compared. Add a field
60
+ * here whenever a writer path or a post-create hook starts stamping one.
61
+ * Ruling R1 extension (PR 292 follow-up; list made exhaustive on the
62
+ * PR 293 landing review, d-2026-10-05-015 finding 2).
63
+ */
64
+ export const REPLAY_IGNORE_FIELDS = Object.freeze([
65
+ // Writer clock: assembleBody / createCn stamp it on every create and
66
+ // strip any caller-supplied value (B1 fix).
67
+ 'createdAt',
68
+ // Writer clock: stamped on every create and bumped by every update,
69
+ // including the classifier's own write-back below.
70
+ 'updatedAt',
71
+ // REQ shape classifier (src/req-detection/persist.js
72
+ // classifyAndPersistReq): src/cli/create.js runs it after every
73
+ // `create req` and persists the block through updateDocument, so the
74
+ // first create leaves a field the replay body never carries.
75
+ 'shapeClassification',
76
+ ]);
77
+ const REPLAY_IGNORE_SET = new Set(REPLAY_IGNORE_FIELDS);
78
+
79
+ /**
80
+ * Deep-equality over JSON-shaped values. Pure. Mirrors
81
+ * `src/define/ledgers.js` `deepEqual` so the R1 extension to `rcf
82
+ * define create` compares content the same way the add path does.
83
+ */
84
+ function deepEqual(a, b) {
85
+ if (Object.is(a, b)) return true;
86
+ if (a === null || b === null) return false;
87
+ if (typeof a !== typeof b) return false;
88
+ if (Array.isArray(a)) {
89
+ if (!Array.isArray(b) || a.length !== b.length) return false;
90
+ for (let i = 0; i < a.length; i += 1) if (!deepEqual(a[i], b[i])) return false;
91
+ return true;
92
+ }
93
+ if (typeof a === 'object') {
94
+ if (typeof b !== 'object' || Array.isArray(b)) return false;
95
+ const ak = Object.keys(a);
96
+ const bk = Object.keys(b);
97
+ if (ak.length !== bk.length) return false;
98
+ for (const k of ak) if (!deepEqual(a[k], b[k])) return false;
99
+ return true;
100
+ }
101
+ return false;
102
+ }
103
+
104
+ /**
105
+ * Compare an on-disk doc body to a would-be create body and return
106
+ * the sorted list of differing field keys. Ignores every field in
107
+ * REPLAY_IGNORE_FIELDS (writer- and classifier-owned stamps), so a
108
+ * verbatim replay does not show up as "updatedAt differs" or
109
+ * "shapeClassification differs". A key present on only one side counts as
110
+ * differing. R1 extension 2026-10-05 (PR 292 follow-up).
111
+ *
112
+ * @param {Record<string, unknown>} existing
113
+ * @param {Record<string, unknown>} candidate
114
+ * @returns {string[]}
115
+ */
116
+ function diffDocFields(existing, candidate) {
117
+ /** @type {Set<string>} */
118
+ const differing = new Set();
119
+ const keys = new Set([...Object.keys(existing ?? {}), ...Object.keys(candidate ?? {})]);
120
+ for (const key of keys) {
121
+ if (REPLAY_IGNORE_SET.has(key)) continue;
122
+ if (!deepEqual(existing?.[key], candidate?.[key])) differing.add(key);
123
+ }
124
+ return [...differing];
125
+ }
126
+
127
+ /**
128
+ * Ruling R1 extension (PR 292 follow-up, amended on the PR 293 landing
129
+ * review): resolve a same-id create replay against the entry already on
130
+ * the tree. Returns null when the id is free; otherwise the
131
+ * `{ status: 'unchanged' }` result (identical content, nothing is
132
+ * written) or a `kind: 'conflict'` refusal naming the differing field
133
+ * keys (exit 3; never a silent overwrite). Shared by the document path
134
+ * (createDocument), the inline AC / TC paths and the CN path so every
135
+ * `rcf define create <kind> --id` follows one rule.
136
+ *
137
+ * @param {object} args
138
+ * @param {string} args.kind - the CLI kind, for the message
139
+ * @param {string} args.id
140
+ * @param {object | null | undefined} args.existing - the entry on the tree
141
+ * @param {object} args.candidate - the would-be entry
142
+ * @param {string} args.filePath - repo-relative file the entry lives in
143
+ * @param {object} [args.extra] - extra fields carried on the unchanged result
144
+ * @returns {object | null}
145
+ */
146
+ function resolveReplay({ kind, id, existing, candidate, filePath, extra = {} }) {
147
+ if (!existing) return null;
148
+ const differing = diffDocFields(existing, candidate);
149
+ if (differing.length === 0) {
150
+ return { id, filePath, ...extra, body: existing, status: 'unchanged' };
151
+ }
152
+ return rcfError({
153
+ kind: 'conflict',
154
+ message: `create ${kind}: id ${id} already exists with different content; differing fields: ${differing.sort().join(', ')}. Use the update verb to patch.`,
155
+ documentId: id,
156
+ filePath,
157
+ });
158
+ }
159
+
55
160
  // ---------------------------------------------------------------------------
56
161
  // B5: post-write validation gate
57
162
  // ---------------------------------------------------------------------------
@@ -496,17 +601,28 @@ export async function createDocument({ projectRoot, tree, kind, body, options =
496
601
  });
497
602
  }
498
603
 
499
- // Allocate id (or take the override).
604
+ // Allocate id (or take the override). Ruling R1 2026-10-05 extended
605
+ // under w-2026-10-05-dave-013 (PR 292 follow-up): a replayed turn
606
+ // that carries `--id <existing>` with identical content is a no-op
607
+ // that returns `{ status: 'unchanged', body: existing }`; different
608
+ // content refuses with `kind: 'conflict'` naming the differing
609
+ // field keys. An unloadable (schema-invalid) doc still refuses
610
+ // unconditionally -- we cannot compare content against a doc we
611
+ // failed to load.
500
612
  let id = options.id;
613
+ /** @type {object | null} */
614
+ let existingDocForReplay = null;
501
615
  if (id) {
502
- // B5: an unloadable (schema-invalid) doc still occupies its id.
503
- if (tree.byId.has(id) || tree.invalidDocs?.has(id)) {
616
+ if (tree.invalidDocs?.has(id)) {
504
617
  return rcfError({
505
618
  kind: 'usage',
506
- message: `create ${kind}: id ${id} is already taken`,
619
+ message: `create ${kind}: id ${id} is already taken (file on disk failed to load; repair before replaying)`,
507
620
  documentId: id,
508
621
  });
509
622
  }
623
+ if (tree.byId.has(id)) {
624
+ existingDocForReplay = tree.byId.get(id);
625
+ }
510
626
  } else {
511
627
  try {
512
628
  id = nextIdForKind(tree, canonical, { parentId });
@@ -528,7 +644,7 @@ export async function createDocument({ projectRoot, tree, kind, body, options =
528
644
  }
529
645
  providedOrder = maxOrder + 1;
530
646
  } else if (Number.isInteger(providedOrder)) {
531
- const collision = siblings.find((sib) => sib.buildOrder === providedOrder);
647
+ const collision = siblings.find((sib) => sib.buildOrder === providedOrder && sib.fbsId !== id);
532
648
  if (collision) {
533
649
  return rcfError({
534
650
  kind: 'usage',
@@ -553,6 +669,16 @@ export async function createDocument({ projectRoot, tree, kind, body, options =
553
669
  });
554
670
  if (validation) return { ...validation, documentId: id };
555
671
 
672
+ // Ruling R1 extension 2026-10-05 (PR 292 follow-up): if the id
673
+ // already exists on the loaded tree, compare the would-be body to
674
+ // the existing doc (ignoring writer-owned timestamps) and either
675
+ // return `{ status: 'unchanged' }` or refuse with
676
+ // `kind: 'conflict'` naming the differing field keys.
677
+ const replay = resolveReplay({
678
+ kind, id, existing: existingDocForReplay, candidate: finalBody, filePath: relPath,
679
+ });
680
+ if (replay) return replay;
681
+
556
682
  // Cross-link ACs must resolve to existing ACs for fbs / ts.
557
683
  if (canonical === 'fbs' || canonical === 'testSuite') {
558
684
  const allAcIds = collectAllAcIds(tree);
@@ -644,10 +770,22 @@ async function createCn({ projectRoot, tree, options, body, walkErrors = [] }) {
644
770
  return rcfError({ kind: 'usage', message: 'create cn: --path is required' });
645
771
  }
646
772
 
773
+ // Ruling R1 extension (PR 293 landing review): a same-id replay is
774
+ // compared against the existing CN once the body is assembled below.
775
+ // An unloadable (schema-invalid) CN, or an id held by another kind,
776
+ // still refuses: there is no CN body to compare against.
647
777
  let id = options.id;
778
+ /** @type {object | null} */
779
+ let existingCnForReplay = null;
648
780
  if (id) {
649
- if (tree.byId.has(id) || tree.invalidDocs?.has(id)) {
650
- return rcfError({ kind: 'usage', message: `create cn: id ${id} is already taken`, documentId: id });
781
+ if (tree.invalidDocs?.has(id)) {
782
+ return rcfError({ kind: 'usage', message: `create cn: id ${id} is already taken (file on disk failed to load; repair before replaying)`, documentId: id });
783
+ }
784
+ if (tree.byId.has(id)) {
785
+ if (tree.kindById.get(id) !== 'codeNode') {
786
+ return rcfError({ kind: 'usage', message: `create cn: id ${id} is already taken`, documentId: id });
787
+ }
788
+ existingCnForReplay = tree.byId.get(id);
651
789
  }
652
790
  } else {
653
791
  id = nextIdForKind(tree, 'codeNode');
@@ -706,6 +844,11 @@ async function createCn({ projectRoot, tree, options, body, walkErrors = [] }) {
706
844
  const validation = validateDocument({ doc: finalBody, kind: 'codeNode', filePath: relPath });
707
845
  if (validation) return { ...validation, documentId: id };
708
846
 
847
+ const replay = resolveReplay({
848
+ kind: 'cn', id, existing: existingCnForReplay, candidate: finalBody, filePath: relPath,
849
+ });
850
+ if (replay) return replay;
851
+
709
852
  const absPath = pathForKindFile(projectRoot, 'codeNode', id);
710
853
  if (await fileExistsOnDisk(absPath)) {
711
854
  return rcfError({
@@ -919,12 +1062,14 @@ async function createInlineAc({ projectRoot, tree, options, body, walkErrors = [
919
1062
  const seedIndex = detectSeededPhantomAcIndex(currentAcs, parentUsId);
920
1063
  const replacingPhantom = seedIndex !== -1 && !options.id;
921
1064
 
1065
+ // Ruling R1 extension (PR 293 landing review): an explicit --id that
1066
+ // already names an AC on the parent US is a replay, resolved against
1067
+ // that AC once the would-be entry is built below.
922
1068
  let acId = options.id;
1069
+ /** @type {object | null} */
1070
+ let existingAcForReplay = null;
923
1071
  if (acId) {
924
- const existing = currentAcs.some((ac) => ac.id === acId);
925
- if (existing) {
926
- return rcfError({ kind: 'usage', message: `create ac: id ${acId} already exists on ${parentUsId}`, documentId: acId });
927
- }
1072
+ existingAcForReplay = currentAcs.find((ac) => ac.id === acId) ?? null;
928
1073
  } else if (replacingPhantom) {
929
1074
  // Reuse the seed's id (AC-<us>-1) so the operator's first AC is -1.
930
1075
  acId = currentAcs[seedIndex].id;
@@ -947,6 +1092,15 @@ async function createInlineAc({ projectRoot, tree, options, body, walkErrors = [
947
1092
  // consumer, so we only serialise when the CLI passed a value.
948
1093
  ...(body?.determinism !== undefined ? { determinism: body.determinism } : {}),
949
1094
  };
1095
+ const acReplay = resolveReplay({
1096
+ kind: 'ac',
1097
+ id: acId,
1098
+ existing: existingAcForReplay,
1099
+ candidate: acEntry,
1100
+ filePath: `rcf/user-stories/${parentUsId.toLowerCase()}.json`,
1101
+ extra: { parentId: parentUsId },
1102
+ });
1103
+ if (acReplay) return acReplay;
950
1104
  const nextAcs = replacingPhantom
951
1105
  ? currentAcs.map((ac, i) => (i === seedIndex ? acEntry : ac))
952
1106
  : [...currentAcs, acEntry];
@@ -1073,9 +1227,14 @@ async function createInlineTc({ projectRoot, tree, options, body, walkErrors = [
1073
1227
  return rcfError({ kind: 'usage', message: `create tc: parent ${parentTsId} has an unrecognised id shape` });
1074
1228
  }
1075
1229
  const tcId = options.id ?? `TC-${tsSuffix}-${slug}`;
1076
- // Slug collision fails explicitly (§D10 OQ-P4-R-1).
1077
1230
  const existing = (ts.testCases ?? []).find((tc) => tc.id === tcId);
1078
- if (existing) {
1231
+ // Ruling R1 extension (PR 293 landing review): idempotency is
1232
+ // id-carried. An explicit --id that already names a TC on the parent
1233
+ // TS is a replay, resolved against that TC once the would-be entry is
1234
+ // built below. A derived-slug collision (no --id) still refuses
1235
+ // explicitly (§D10 OQ-P4-R-1).
1236
+ const replayingTc = Boolean(options.id) && Boolean(existing);
1237
+ if (existing && !replayingTc) {
1079
1238
  return rcfError({
1080
1239
  kind: 'usage',
1081
1240
  message: `create tc: slug collision on ${tcId}, supply --slug explicitly`,
@@ -1089,6 +1248,15 @@ async function createInlineTc({ projectRoot, tree, options, body, walkErrors = [
1089
1248
  status: body?.status ?? 'pending',
1090
1249
  testPointer,
1091
1250
  };
1251
+ const tcReplay = resolveReplay({
1252
+ kind: 'tc',
1253
+ id: tcId,
1254
+ existing: replayingTc ? existing : null,
1255
+ candidate: tcEntry,
1256
+ filePath: `rcf/test-suites/${parentTsId.toLowerCase()}.json`,
1257
+ extra: { parentId: parentTsId },
1258
+ });
1259
+ if (tcReplay) return tcReplay;
1092
1260
  const nextTs = {
1093
1261
  ...ts,
1094
1262
  testCases: [...(ts.testCases ?? []), tcEntry],
@@ -102,7 +102,7 @@ export class LedgerError extends Error {
102
102
  * @param {string} message
103
103
  * @param {object} [opts]
104
104
  * @param {string} [opts.filePath]
105
- * @param {'parseFailure' | 'schemaFailure' | 'ioFailure' | 'usage'} [opts.code]
105
+ * @param {'parseFailure' | 'schemaFailure' | 'ioFailure' | 'usage' | 'conflict'} [opts.code]
106
106
  * @param {string} [opts.field]
107
107
  */
108
108
  constructor(message, opts = {}) {
@@ -390,26 +390,105 @@ export function nextIdFor(name, body) {
390
390
  * updated body. Pure over its inputs (does not write to disk); the
391
391
  * CLI calls `saveLedger` with the result.
392
392
  *
393
+ * Ruling R1 2026-10-05 (w-2026-10-05-dave-001): `add` is idempotent by
394
+ * id. When `args.id` is set (the harness carries it on a replayed
395
+ * turn):
396
+ * - no entry with that id exists: honour the override and add the
397
+ * entry at that id (any gaps in the id sequence are legal; the
398
+ * per-ledger validator enforces uniqueness, not contiguity);
399
+ * - entry with that id exists and the content matches: return
400
+ * `{ status: 'unchanged', body, entry }`, no new write;
401
+ * - entry with that id exists and the content differs: throw a
402
+ * `LedgerError` (code 'conflict') naming the id and the differing
403
+ * field keys so the CLI exits 3. Update is a separate verb.
404
+ * When `args.id` is omitted the next id is minted as before (the
405
+ * pre-R1 behaviour; a replayed turn without a carried id still works
406
+ * but duplicates the entry at a new id, which is why the harness is
407
+ * responsible for threading --id back).
408
+ *
393
409
  * @param {object} args
394
410
  * @param {LedgerName} args.name
395
411
  * @param {Record<string, unknown[]>} args.body
396
412
  * @param {Record<string, unknown>} args.entry - fields other than id / addedAt / status
413
+ * @param {number} [args.id] - optional id override (R1)
397
414
  * @param {string} [args.now] - ISO timestamp; defaults to Date.now()
398
- * @returns {{ body: Record<string, unknown[]>, entry: Record<string, unknown> }}
415
+ * @returns {{ body: Record<string, unknown[]>, entry: Record<string, unknown>, status?: 'added' | 'unchanged' }}
399
416
  */
400
- export function addEntry({ name, body, entry, now }) {
417
+ export function addEntry({ name, body, entry, id, now }) {
401
418
  const cfg = LEDGER_CONFIG[name];
402
419
  if (!cfg) throw new LedgerError(`Unknown ledger '${name}'.`, { code: 'usage' });
403
- const id = nextIdFor(name, body);
420
+ const list = /** @type {any[]} */ (body[cfg.arrayKey] ?? []);
421
+ let resolvedId;
422
+ if (id !== undefined) {
423
+ if (!Number.isInteger(id) || id < 1) {
424
+ throw new LedgerError(`${name} add --id must be a positive integer, got ${id}.`, {
425
+ filePath: ledgerRelPath(name), field: 'id', code: 'usage',
426
+ });
427
+ }
428
+ const existing = list.find((e) => Number(e?.id) === Number(id));
429
+ if (existing) {
430
+ // R1 idempotency check: compare caller-supplied fields against the
431
+ // existing entry. `addedAt` and `status` default on first add, so
432
+ // they are compared only when the caller explicitly supplied them.
433
+ const differing = [];
434
+ for (const [key, value] of Object.entries(entry)) {
435
+ if (value === undefined) continue;
436
+ if (key === 'id') continue;
437
+ if (key === 'addedAt' || key === 'status') {
438
+ // Default values auto-populated on first add; skip unless the
439
+ // caller passed them explicitly AND they differ.
440
+ if (existing[key] !== value) differing.push(key);
441
+ continue;
442
+ }
443
+ if (!deepEqual(existing[key], value)) differing.push(key);
444
+ }
445
+ if (differing.length === 0) {
446
+ return { body, entry: existing, status: 'unchanged' };
447
+ }
448
+ throw new LedgerError(
449
+ `${name} ledger entry ${id} already exists with different content; differing fields: ${differing.sort().join(', ')}. Use the update verb to patch.`,
450
+ { filePath: ledgerRelPath(name), field: `id ${id}`, code: 'conflict' },
451
+ );
452
+ }
453
+ resolvedId = id;
454
+ } else {
455
+ resolvedId = nextIdFor(name, body);
456
+ }
404
457
  const full = {
405
- id,
458
+ id: resolvedId,
406
459
  ...entry,
407
460
  addedAt: entry.addedAt ?? now ?? new Date().toISOString(),
408
461
  status: entry.status ?? 'open',
409
462
  };
410
- const nextBody = { ...body, [cfg.arrayKey]: [...(body[cfg.arrayKey] ?? []), full] };
463
+ const nextBody = { ...body, [cfg.arrayKey]: [...list, full] };
411
464
  validateLedger(name, nextBody, ledgerRelPath(name));
412
- return { body: nextBody, entry: full };
465
+ return { body: nextBody, entry: full, status: 'added' };
466
+ }
467
+
468
+ /**
469
+ * Deep-equality over JSON-shaped values. Used by R1's idempotency
470
+ * check to compare a caller-supplied field against the existing entry.
471
+ * Arrays and plain objects are compared structurally; primitives via
472
+ * Object.is. Pure.
473
+ */
474
+ function deepEqual(a, b) {
475
+ if (Object.is(a, b)) return true;
476
+ if (a === null || b === null) return false;
477
+ if (typeof a !== typeof b) return false;
478
+ if (Array.isArray(a)) {
479
+ if (!Array.isArray(b) || a.length !== b.length) return false;
480
+ for (let i = 0; i < a.length; i += 1) if (!deepEqual(a[i], b[i])) return false;
481
+ return true;
482
+ }
483
+ if (typeof a === 'object') {
484
+ if (typeof b !== 'object' || Array.isArray(b)) return false;
485
+ const ak = Object.keys(a);
486
+ const bk = Object.keys(b);
487
+ if (ak.length !== bk.length) return false;
488
+ for (const k of ak) if (!deepEqual(a[k], b[k])) return false;
489
+ return true;
490
+ }
491
+ return false;
413
492
  }
414
493
 
415
494
  /**
package/src/mcp/tools.js CHANGED
@@ -639,7 +639,7 @@ const DEFINITIONS = [
639
639
  properties: {
640
640
  kind: { type: 'string', enum: KIND_ENUM, description: 'Document kind' },
641
641
  parent: { type: 'string', description: 'Parent document id; required for every kind except cn (which has no parent)' },
642
- id: { type: 'string', description: 'Override the auto-assigned id (refuses on collision)' },
642
+ id: { type: 'string', description: 'Override the auto-assigned id. Idempotent by id: an existing id with identical content is a no-op; different content refuses as a conflict naming the differing fields (use rcf_update to patch)' },
643
643
  title: { type: 'string', description: 'Required for req / us / tac / adr / fbs / ts' },
644
644
  description: { type: 'string', description: 'Required for ac / tc' },
645
645
  purpose: { type: 'string', description: 'Required for ts' },