yarramate 0.7.0 → 0.8.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.
@@ -1,15 +1,18 @@
1
1
  format: yarramate/question-catalogue/v1
2
2
  id: core-enrichment
3
- version: "0.3"
3
+ version: "0.4"
4
4
  profile: yarramate/core@0.1
5
5
  presentation:
6
6
  title: Core enrichment interview
7
7
  description: >-
8
- The guided design path: motivation, business, and application waves plus
9
- cross-cutting hygiene (technology and implementation follow in a later
10
- version). Each question states the decision its answer changes; a
11
- question that cannot is deleted, not softened. Adequacy is enforced by
12
- linkage depth and attestation, never by reading words.
8
+ The guided design path: motivation, business, application, technology,
9
+ and implementation waves plus cross-cutting hygiene. Each question
10
+ states the decision its answer changes; a question that cannot is
11
+ deleted, not softened. Adequacy is enforced by linkage depth and
12
+ attestation, never by reading words. Versioning is additive within a
13
+ major version: every question records the catalogue version it arrived
14
+ in (ADR 0063), and a completed interview honestly reopens when the
15
+ path deepens.
13
16
 
14
17
  waves:
15
18
  - id: motivation
@@ -23,6 +26,16 @@ waves:
23
26
  description: >-
24
27
  How declared services are realized, performed, and fed with
25
28
  information.
29
+ - id: technology
30
+ name: Technology
31
+ description: >-
32
+ Where the declared applications actually run and what materializes
33
+ them.
34
+ - id: implementation
35
+ name: Implementation
36
+ description: >-
37
+ How the planned architecture becomes real: work, deliverables, and
38
+ the plateaus between here and there.
26
39
  - id: hygiene
27
40
  name: Model hygiene
28
41
  description: Cross-cutting completeness that keeps every wave honest.
@@ -31,6 +44,7 @@ questions:
31
44
  # ---- motivation ----------------------------------------------------------
32
45
  - id: outcome-missing
33
46
  wave: motivation
47
+ since: "0.1"
34
48
  scope: workspace
35
49
  trigger:
36
50
  - condition: no-subject-of-kind
@@ -50,6 +64,7 @@ questions:
50
64
 
51
65
  - id: stakeholders-missing
52
66
  wave: motivation
67
+ since: "0.1"
53
68
  scope: workspace
54
69
  trigger:
55
70
  - condition: no-subject-of-kind
@@ -68,6 +83,7 @@ questions:
68
83
 
69
84
  - id: constraints-missing
70
85
  wave: motivation
86
+ since: "0.1"
71
87
  scope: workspace
72
88
  trigger:
73
89
  - condition: no-subject-of-kind
@@ -86,6 +102,7 @@ questions:
86
102
 
87
103
  - id: goal-unrealized
88
104
  wave: motivation
105
+ since: "0.1"
89
106
  scope: subject
90
107
  subjects:
91
108
  kinds:
@@ -110,6 +127,7 @@ questions:
110
127
 
111
128
  - id: goal-no-driver
112
129
  wave: motivation
130
+ since: "0.3"
113
131
  scope: subject
114
132
  subjects:
115
133
  kinds:
@@ -137,6 +155,7 @@ questions:
137
155
 
138
156
  - id: driver-influences-nothing
139
157
  wave: motivation
158
+ since: "0.3"
140
159
  scope: subject
141
160
  subjects:
142
161
  kinds:
@@ -158,6 +177,7 @@ questions:
158
177
 
159
178
  - id: stakeholder-unconcerned
160
179
  wave: motivation
180
+ since: "0.3"
161
181
  scope: subject
162
182
  subjects:
163
183
  kinds:
@@ -180,6 +200,7 @@ questions:
180
200
 
181
201
  - id: requirement-unrealized
182
202
  wave: motivation
203
+ since: "0.3"
183
204
  scope: subject
184
205
  subjects:
185
206
  kinds:
@@ -203,6 +224,7 @@ questions:
203
224
 
204
225
  - id: principle-unapplied
205
226
  wave: motivation
227
+ since: "0.3"
206
228
  scope: subject
207
229
  subjects:
208
230
  kinds:
@@ -225,6 +247,7 @@ questions:
225
247
 
226
248
  - id: assessment-unlinked
227
249
  wave: motivation
250
+ since: "0.3"
228
251
  scope: subject
229
252
  subjects:
230
253
  kinds:
@@ -243,6 +266,7 @@ questions:
243
266
 
244
267
  - id: motivation-unattested
245
268
  wave: motivation
269
+ since: "0.3"
246
270
  scope: subject
247
271
  subjects:
248
272
  kinds:
@@ -268,6 +292,7 @@ questions:
268
292
  # ---- business ------------------------------------------------------------
269
293
  - id: service-consumer-unknown
270
294
  wave: business
295
+ since: "0.1"
271
296
  scope: subject
272
297
  subjects:
273
298
  kinds:
@@ -291,6 +316,7 @@ questions:
291
316
 
292
317
  - id: owner-missing
293
318
  wave: business
319
+ since: "0.1"
294
320
  scope: subject
295
321
  subjects:
296
322
  kinds:
@@ -313,6 +339,7 @@ questions:
313
339
 
314
340
  - id: actor-unassigned
315
341
  wave: business
342
+ since: "0.1"
316
343
  scope: subject
317
344
  subjects:
318
345
  kinds:
@@ -335,6 +362,7 @@ questions:
335
362
 
336
363
  - id: information-unaccessed
337
364
  wave: business
365
+ since: "0.1"
338
366
  scope: subject
339
367
  subjects:
340
368
  kinds:
@@ -358,6 +386,7 @@ questions:
358
386
 
359
387
  - id: no-service-declared
360
388
  wave: business
389
+ since: "0.1"
361
390
  scope: workspace
362
391
  trigger:
363
392
  - condition: no-subject-of-kind
@@ -378,6 +407,7 @@ questions:
378
407
 
379
408
  - id: service-realizes-no-motivation
380
409
  wave: business
410
+ since: "0.3"
381
411
  scope: subject
382
412
  subjects:
383
413
  kinds:
@@ -405,6 +435,7 @@ questions:
405
435
 
406
436
  - id: process-untriggered
407
437
  wave: business
438
+ since: "0.3"
408
439
  scope: subject
409
440
  subjects:
410
441
  kinds:
@@ -427,6 +458,7 @@ questions:
427
458
 
428
459
  - id: business-service-unrealized
429
460
  wave: business
461
+ since: "0.3"
430
462
  scope: subject
431
463
  subjects:
432
464
  kinds:
@@ -460,6 +492,7 @@ questions:
460
492
  # ---- application ---------------------------------------------------------
461
493
  - id: app-service-unrealized
462
494
  wave: application
495
+ since: "0.3"
463
496
  scope: subject
464
497
  subjects:
465
498
  kinds:
@@ -490,6 +523,7 @@ questions:
490
523
 
491
524
  - id: component-realizes-nothing
492
525
  wave: application
526
+ since: "0.3"
493
527
  scope: subject
494
528
  subjects:
495
529
  kinds:
@@ -515,6 +549,7 @@ questions:
515
549
 
516
550
  - id: behavior-unassigned
517
551
  wave: application
552
+ since: "0.3"
518
553
  scope: subject
519
554
  subjects:
520
555
  kinds:
@@ -546,6 +581,7 @@ questions:
546
581
 
547
582
  - id: event-triggers-nothing
548
583
  wave: application
584
+ since: "0.3"
549
585
  scope: subject
550
586
  subjects:
551
587
  kinds:
@@ -568,6 +604,7 @@ questions:
568
604
 
569
605
  - id: information-unowned
570
606
  wave: application
607
+ since: "0.3"
571
608
  scope: subject
572
609
  subjects:
573
610
  kinds:
@@ -589,6 +626,7 @@ questions:
589
626
 
590
627
  - id: planned-design-unattested
591
628
  wave: application
629
+ since: "0.3"
592
630
  scope: subject
593
631
  subjects:
594
632
  kinds:
@@ -612,9 +650,246 @@ questions:
612
650
  topic "design-review" (revoke by deleting it when the design
613
651
  changes).
614
652
 
653
+ # ---- technology ----------------------------------------------------------
654
+ - id: component-unhosted
655
+ wave: technology
656
+ since: "0.4"
657
+ scope: subject
658
+ subjects:
659
+ kinds:
660
+ - yarramate/core@0.1#applicationComponent
661
+ kindMatching: exact
662
+ statuses:
663
+ - planned
664
+ - current
665
+ trigger:
666
+ - condition: missing-linkage
667
+ kinds:
668
+ - yarramate/core@0.1#serving
669
+ - yarramate/core@0.1#assignment
670
+ direction: incoming
671
+ counterpartKinds:
672
+ - yarramate/core@0.1#node
673
+ - yarramate/core@0.1#device
674
+ - yarramate/core@0.1#systemSoftware
675
+ - yarramate/core@0.1#technologyService
676
+ question: >-
677
+ Where does {subject.name} run?
678
+ materiality: >-
679
+ The hosting boundary decides latency, failure domain, scaling
680
+ model, and data residency; a component with no declared runtime is
681
+ deployed by whoever gets there first. Matching is exact by intent:
682
+ profile-derived module kinds inherit their deployable parent's
683
+ hosting rather than answering separately.
684
+ authority: either
685
+ resolution: >-
686
+ Add the node, device, or system software that hosts it with a
687
+ serving or assignment relationship into the component.
688
+
689
+ - id: node-serves-nothing
690
+ wave: technology
691
+ since: "0.4"
692
+ scope: subject
693
+ subjects:
694
+ kinds:
695
+ - yarramate/core@0.1#node
696
+ - yarramate/core@0.1#device
697
+ - yarramate/core@0.1#systemSoftware
698
+ trigger:
699
+ - condition: missing-relationship
700
+ kinds:
701
+ - yarramate/core@0.1#serving
702
+ - yarramate/core@0.1#assignment
703
+ direction: outgoing
704
+ question: >-
705
+ What does {subject.name} host or serve?
706
+ materiality: >-
707
+ Declared infrastructure that serves nothing is either cost without
708
+ purpose or missing the links that justify it; both change the
709
+ deployment budget.
710
+ authority: either
711
+ resolution: >-
712
+ Add serving or assignment relationships to the applications and
713
+ behavior it hosts, or remove it.
714
+
715
+ - id: technology-service-unrealized
716
+ wave: technology
717
+ since: "0.4"
718
+ scope: subject
719
+ subjects:
720
+ kinds:
721
+ - yarramate/core@0.1#technologyService
722
+ statuses:
723
+ - planned
724
+ - current
725
+ trigger:
726
+ - condition: missing-linkage
727
+ kinds:
728
+ - yarramate/core@0.1#realization
729
+ direction: incoming
730
+ counterpartKinds:
731
+ - yarramate/core@0.1#node
732
+ - yarramate/core@0.1#systemSoftware
733
+ - yarramate/core@0.1#technologyFunction
734
+ - yarramate/core@0.1#technologyProcess
735
+ question: >-
736
+ What provides {subject.name}?
737
+ materiality: >-
738
+ A technology service with no realizing node or behavior is a
739
+ dependency assumed rather than provided; outages and upgrades have
740
+ no owner in the model.
741
+ authority: either
742
+ resolution: >-
743
+ Add realization from the node, system software, or technology
744
+ behavior that provides the service.
745
+
746
+ - id: artifact-unassigned
747
+ wave: technology
748
+ since: "0.4"
749
+ scope: subject
750
+ subjects:
751
+ kinds:
752
+ - yarramate/core@0.1#artifact
753
+ kindMatching: exact
754
+ trigger:
755
+ - condition: missing-relationship
756
+ kinds:
757
+ - yarramate/core@0.1#assignment
758
+ - yarramate/core@0.1#realization
759
+ direction: any
760
+ question: >-
761
+ What deploys {subject.name}, and what does it materialize?
762
+ materiality: >-
763
+ An artifact with no deployment target and nothing it realizes is a
764
+ build output the architecture cannot place; release engineering
765
+ starts from exactly these links.
766
+ authority: either
767
+ resolution: >-
768
+ Assign the artifact to the node that deploys it and add realization
769
+ to the component or data it materializes.
770
+
771
+ # ---- implementation ------------------------------------------------------
772
+ - id: workpackage-delivers-nothing
773
+ wave: implementation
774
+ since: "0.4"
775
+ scope: subject
776
+ subjects:
777
+ kinds:
778
+ - yarramate/core@0.1#workPackage
779
+ trigger:
780
+ - condition: missing-relationship
781
+ kinds:
782
+ - yarramate/core@0.1#realization
783
+ - yarramate/core@0.1#aggregation
784
+ direction: outgoing
785
+ question: >-
786
+ What does {subject.name} produce?
787
+ materiality: >-
788
+ Work with no deliverable cannot be accepted or declared done; the
789
+ deliverable link is what makes progress reviewable rather than
790
+ reported.
791
+ authority: either
792
+ resolution: >-
793
+ Add realization from the work package to its deliverables.
794
+
795
+ - id: workpackage-unassigned
796
+ wave: implementation
797
+ since: "0.4"
798
+ scope: subject
799
+ subjects:
800
+ kinds:
801
+ - yarramate/core@0.1#workPackage
802
+ trigger:
803
+ - condition: missing-linkage
804
+ kinds:
805
+ - yarramate/core@0.1#assignment
806
+ direction: incoming
807
+ counterpartKinds:
808
+ - yarramate/core@0.1#businessActor
809
+ - yarramate/core@0.1#businessRole
810
+ question: >-
811
+ Who is committed to {subject.name}?
812
+ materiality: >-
813
+ An unassigned work package is a plan without capacity; commitment
814
+ is the difference between a roadmap and a wish list.
815
+ authority: human
816
+ resolution: >-
817
+ Add assignment from the actor or role that owns the work.
818
+
819
+ - id: deliverable-realizes-nothing
820
+ wave: implementation
821
+ since: "0.4"
822
+ scope: subject
823
+ subjects:
824
+ kinds:
825
+ - yarramate/core@0.1#deliverable
826
+ kindMatching: exact
827
+ trigger:
828
+ - condition: missing-relationship
829
+ kinds:
830
+ - yarramate/core@0.1#realization
831
+ direction: outgoing
832
+ question: >-
833
+ What does {subject.name} make real?
834
+ materiality: >-
835
+ A deliverable that realizes no architecture element is effort
836
+ disconnected from the design; its acceptance criteria cannot be
837
+ stated architecturally.
838
+ authority: either
839
+ resolution: >-
840
+ Add realization from the deliverable to the plateau, requirement,
841
+ or element it brings into being.
842
+
843
+ - id: plateau-aggregates-nothing
844
+ wave: implementation
845
+ since: "0.4"
846
+ scope: subject
847
+ subjects:
848
+ kinds:
849
+ - yarramate/core@0.1#plateau
850
+ trigger:
851
+ - condition: missing-relationship
852
+ kinds:
853
+ - yarramate/core@0.1#aggregation
854
+ - yarramate/core@0.1#composition
855
+ direction: outgoing
856
+ question: >-
857
+ Which architecture does {subject.name} stabilize?
858
+ materiality: >-
859
+ A plateau that aggregates nothing describes no state anyone can
860
+ stand on; migration planning needs to know what is stable when.
861
+ authority: either
862
+ resolution: >-
863
+ Aggregate the elements that are current during this plateau, or use
864
+ architecture states instead and remove it.
865
+
866
+ - id: gap-unaddressed
867
+ wave: implementation
868
+ since: "0.4"
869
+ scope: subject
870
+ subjects:
871
+ kinds:
872
+ - yarramate/core@0.1#gap
873
+ trigger:
874
+ - condition: missing-relationship
875
+ kinds:
876
+ - yarramate/core@0.1#association
877
+ - yarramate/core@0.1#realization
878
+ direction: any
879
+ question: >-
880
+ What closes {subject.name}?
881
+ materiality: >-
882
+ A named gap nothing addresses is a known risk with no plan; either
883
+ work closes it or the acceptance of it should be on record.
884
+ authority: human
885
+ resolution: >-
886
+ Associate the gap with the plateaus it separates and the
887
+ deliverable that closes it, or record the decision to accept it.
888
+
615
889
  # ---- hygiene -------------------------------------------------------------
616
890
  - id: concept-isolated
617
891
  wave: hygiene
892
+ since: "0.1"
618
893
  scope: subject
619
894
  subjects:
620
895
  kinds:
@@ -640,6 +915,7 @@ questions:
640
915
 
641
916
  - id: concept-undescribed
642
917
  wave: hygiene
918
+ since: "0.1"
643
919
  scope: subject
644
920
  subjects:
645
921
  kinds:
@@ -663,6 +939,7 @@ questions:
663
939
 
664
940
  - id: status-missing
665
941
  wave: hygiene
942
+ since: "0.1"
666
943
  scope: subject
667
944
  subjects:
668
945
  kinds:
@@ -685,6 +962,7 @@ questions:
685
962
 
686
963
  - id: states-undefined
687
964
  wave: hygiene
965
+ since: "0.1"
688
966
  scope: workspace
689
967
  trigger:
690
968
  - condition: no-state-defined
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
- import { isSeq, parseDocument } from 'yaml';
3
+ import { isMap, isScalar, isSeq, parseDocument, stringify, } from 'yaml';
4
4
  import Ajv2020Module from 'ajv/dist/2020.js';
5
5
  import { diagnosticJson, humanDiagnostics, usage, } from './cli-support.js';
6
6
  import { compileWorkspace } from './compiler.js';
@@ -11,48 +11,170 @@ import operationsSchema from '../schema/yarramate-operations.schema.json' with {
11
11
  };
12
12
  const Ajv2020 = Ajv2020Module.default;
13
13
  const validateOperations = new Ajv2020({ allErrors: true }).compile(operationsSchema);
14
- // Scalar fields replace; list fields append. An answer enriches what is
15
- // there it never silently shrinks it (removals stay Git edits).
14
+ // Scalar fields replace; list fields append; `remove` retracts (ADR 0062).
15
+ // An answer enriches what is there and may explicitly take back what it
16
+ // asserted — it never silently shrinks anything.
16
17
  const SCALAR_CONCEPT_FIELDS = ['kind', 'name', 'description', 'status', 'owner'];
17
18
  const LIST_CONCEPT_FIELDS = ['constraints', 'references', 'presentIn', 'attestations'];
18
19
  const SCALAR_RELATIONSHIP_FIELDS = ['kind', 'from', 'to', 'name', 'description', 'status', 'mode', 'content'];
19
20
  const LIST_RELATIONSHIP_FIELDS = ['references', 'presentIn'];
20
- const appendBlockItem = (document, collection, item) => {
21
- document.addIn([collection], item);
22
- const sequence = document.getIn([collection], true);
23
- if (isSeq(sequence)) {
24
- sequence.flow = false;
21
+ // ---------------------------------------------------------------------------
22
+ // The splice layer. Every operation becomes a minimal text edit against the
23
+ // document's current source, so bytes the batch never touched stay
24
+ // byte-identical — an apply diff is exactly the answer it landed (#114).
25
+ // The atomic compile gate below validates the spliced text itself, so any
26
+ // splice defect rejects the batch loudly instead of corrupting a document.
27
+ const lineStartOf = (source, offset) => source.lastIndexOf('\n', offset - 1) + 1;
28
+ const indentAt = (source, offset) => offset - lineStartOf(source, offset);
29
+ // End of the last line a node occupies, extended through its newline.
30
+ const lineEndAfter = (source, offset) => {
31
+ const newline = source.indexOf('\n', Math.max(offset - 1, 0));
32
+ return newline === -1 ? source.length : newline + 1;
33
+ };
34
+ const reindent = (text, indent) => text.split('\n').join(`\n${' '.repeat(indent)}`);
35
+ // A plain value as YAML source. lineWidth 0 keeps strings we author on one
36
+ // line; genuinely multi-line strings become block scalars and are re-indented
37
+ // by the caller.
38
+ const valueText = (value) => stringify(value, { lineWidth: 0 }).trimEnd();
39
+ const pairFor = (map, key) => map.items.find((pair) => isScalar(pair.key) && pair.key.value === key);
40
+ const nodeRange = (node) => {
41
+ const range = node.range;
42
+ if (range === undefined) {
43
+ throw new Error('YAML node has no source range');
44
+ }
45
+ return range;
46
+ };
47
+ // Renders `items` as block sequence entries at the given marker indent.
48
+ const sequenceEntries = (items, markerIndent) => {
49
+ const rendered = stringify(items, { lineWidth: 0 }).trimEnd();
50
+ return `${' '.repeat(markerIndent)}${reindent(rendered, markerIndent)}`;
51
+ };
52
+ const splice = (source, start, end, text) => source.slice(0, start) + text + source.slice(end);
53
+ // Insert a newline-terminated block at a line boundary, tolerating a
54
+ // source that does not end in a newline.
55
+ const insertBlock = (source, insertAt, block) => splice(source, insertAt, insertAt, insertAt > 0 && source[insertAt - 1] !== '\n' ? `\n${block}` : block);
56
+ // Node ranges may extend past their trailing newline to the start of the
57
+ // next line; anchor insertions on the last CONTENT line instead.
58
+ const afterContentLine = (source, offset) => {
59
+ let anchor = offset;
60
+ while (anchor > 0 && source[anchor - 1] === '\n')
61
+ anchor -= 1;
62
+ return lineEndAfter(source, anchor);
63
+ };
64
+ // Where a new field of an item lands: after the last line of the item's
65
+ // final pair.
66
+ const itemFieldInsertAt = (source, map) => {
67
+ const lastPair = map.items[map.items.length - 1];
68
+ const anchor = lastPair.value === null || lastPair.value === undefined
69
+ ? nodeRange(lastPair.key)[2]
70
+ : nodeRange(lastPair.value)[1];
71
+ return afterContentLine(source, anchor);
72
+ };
73
+ // Appends one item to a top-level block collection (`concepts:` or
74
+ // `relationships:`), creating or converting the collection when needed.
75
+ const appendCollectionItem = (source, collection, item) => {
76
+ const document = parseDocument(source);
77
+ const root = document.contents;
78
+ if (!isMap(root))
79
+ throw new Error('Document root is not a mapping');
80
+ const pair = pairFor(root, collection);
81
+ if (pair === undefined) {
82
+ const base = source.endsWith('\n') ? source : `${source}\n`;
83
+ return `${base}${collection}:\n${sequenceEntries([item], 2)}\n`;
84
+ }
85
+ const sequence = pair.value;
86
+ if (isSeq(sequence) && !sequence.flow && sequence.items.length > 0) {
87
+ const lastItem = sequence.items[sequence.items.length - 1];
88
+ const markerIndent = indentAt(source, nodeRange(lastItem)[0]) - 2;
89
+ const insertAt = lineEndAfter(source, nodeRange(lastItem)[2]);
90
+ return insertBlock(source, insertAt, `${sequenceEntries([item], markerIndent)}\n`);
25
91
  }
92
+ if (!isSeq(sequence)) {
93
+ // `concepts:` with no value at all: append the block under the key.
94
+ const insertAt = lineEndAfter(source, nodeRange(pair.key)[2]);
95
+ return insertBlock(source, insertAt, `${sequenceEntries([item], 2)}\n`);
96
+ }
97
+ // Empty or flow collection: replace the whole value with a block
98
+ // sequence carrying any existing entries plus the new one, consuming
99
+ // the space that separated it from the key.
100
+ const existing = sequence.items.length > 0
101
+ ? sequence.toJSON()
102
+ : [];
103
+ let [start] = nodeRange(sequence);
104
+ const valueEnd = nodeRange(sequence)[1];
105
+ while (start > 0 && source[start - 1] === ' ')
106
+ start -= 1;
107
+ return splice(source, start, valueEnd, `\n${sequenceEntries([...existing, item], 2)}`);
26
108
  };
27
- const findItem = (document, collection, id) => {
28
- const sequence = document.get(collection, true);
29
- if (!isSeq(sequence))
109
+ const itemMap = (source, collection, id) => {
110
+ const document = parseDocument(source);
111
+ const root = document.contents;
112
+ if (!isMap(root))
113
+ return undefined;
114
+ const pair = pairFor(root, collection);
115
+ if (pair === undefined || !isSeq(pair.value))
30
116
  return undefined;
31
- return sequence.items.find((item) => typeof item === 'object' &&
32
- item !== null &&
33
- 'get' in item &&
34
- item.get('id') === id);
117
+ const found = pair.value.items.find((candidate) => isMap(candidate) &&
118
+ candidate.items.some((field) => isScalar(field.key) &&
119
+ field.key.value === 'id' &&
120
+ isScalar(field.value) &&
121
+ field.value.value === id));
122
+ return found === undefined ? undefined : { map: found };
35
123
  };
36
- const applyFields = (document, item, fields, scalars, lists) => {
37
- for (const key of scalars) {
38
- if (fields[key] !== undefined)
39
- item.set(key, fields[key]);
124
+ // The indent item fields sit at, read off the item's own first field.
125
+ const fieldIndentOf = (source, map) => indentAt(source, nodeRange(map.items[0].key)[0]);
126
+ const setScalarField = (source, map, key, value) => {
127
+ const indent = fieldIndentOf(source, map);
128
+ const rendered = reindent(valueText(value), indent + 2);
129
+ const pair = pairFor(map, key);
130
+ if (pair === undefined) {
131
+ return insertBlock(source, itemFieldInsertAt(source, map), `${' '.repeat(indent)}${key}: ${rendered}\n`);
40
132
  }
41
- for (const key of lists) {
42
- const additions = fields[key];
43
- if (additions === undefined || additions.length === 0)
44
- continue;
45
- const existing = item.get(key);
46
- if (existing === undefined) {
47
- item.set(key, additions);
48
- }
49
- else if (isSeq(existing)) {
50
- for (const entry of additions) {
51
- existing.items.push(document.createNode(entry));
52
- }
53
- }
133
+ const [start, valueEnd] = nodeRange(pair.value);
134
+ return splice(source, start, valueEnd, rendered);
135
+ };
136
+ const appendListField = (source, map, key, additions) => {
137
+ const indent = fieldIndentOf(source, map);
138
+ const pair = pairFor(map, key);
139
+ if (pair === undefined) {
140
+ return insertBlock(source, itemFieldInsertAt(source, map), `${' '.repeat(indent)}${key}:\n${sequenceEntries(additions, indent + 2)}\n`);
141
+ }
142
+ const sequence = pair.value;
143
+ if (isSeq(sequence) && !sequence.flow && sequence.items.length > 0) {
144
+ const lastItem = sequence.items[sequence.items.length - 1];
145
+ const markerIndent = indentAt(source, nodeRange(lastItem)[0]) - 2;
146
+ const insertAt = lineEndAfter(source, nodeRange(lastItem)[2]);
147
+ return insertBlock(source, insertAt, `${sequenceEntries(additions, markerIndent)}\n`);
54
148
  }
149
+ const existing = isSeq(sequence) && sequence.items.length > 0
150
+ ? sequence.toJSON()
151
+ : [];
152
+ const merged = [...existing, ...additions];
153
+ const [start, valueEnd] = isSeq(sequence)
154
+ ? nodeRange(sequence)
155
+ : nodeRange(pair.value);
156
+ if (isSeq(sequence) && sequence.flow) {
157
+ const flow = stringify(merged, {
158
+ collectionStyle: 'flow',
159
+ lineWidth: 0,
160
+ }).trimEnd();
161
+ return splice(source, start, valueEnd, flow);
162
+ }
163
+ return splice(source, start, valueEnd, `\n${sequenceEntries(merged, indent + 2)}`);
164
+ };
165
+ // Retraction (#115): delete the field's whole entry, from the start of its
166
+ // key line through the end of its value's last line.
167
+ const removeField = (source, map, key) => {
168
+ const pair = pairFor(map, key);
169
+ if (pair === undefined)
170
+ return undefined;
171
+ const start = lineStartOf(source, nodeRange(pair.key)[0]);
172
+ const valueEnd = pair.value === null || pair.value === undefined
173
+ ? nodeRange(pair.key)[2]
174
+ : nodeRange(pair.value)[2];
175
+ return splice(source, start, lineEndAfter(source, valueEnd), '');
55
176
  };
177
+ // ---------------------------------------------------------------------------
56
178
  export function runApplyCommand(options, cwd) {
57
179
  const json = options.includes('--json');
58
180
  const rest = options.filter((option) => option !== '--json');
@@ -91,7 +213,7 @@ export function runApplyCommand(options, cwd) {
91
213
  // Documents are addressed by their manifest paths; an operation aimed
92
214
  // anywhere else is rejected before anything is touched.
93
215
  const workspaceDocuments = new Map(workspace.documents.map((path) => [resolve(cwd, path), path]));
94
- const parsed = new Map();
216
+ const candidates = new Map();
95
217
  const counts = {
96
218
  addedConcepts: 0,
97
219
  addedRelationships: 0,
@@ -112,35 +234,63 @@ export function runApplyCommand(options, cwd) {
112
234
  locate(`Operation ${index} targets "${operation.document}", which is not a document of workspace "${workspace.id}"`),
113
235
  ]);
114
236
  }
115
- let document = parsed.get(absolute);
116
- if (document === undefined) {
117
- document = parseDocument(readFileSync(absolute, 'utf8'));
118
- parsed.set(absolute, document);
237
+ let source = candidates.get(absolute);
238
+ if (source === undefined) {
239
+ source = readFileSync(absolute, 'utf8');
119
240
  }
120
241
  if (operation.op === 'add-concept') {
121
- appendBlockItem(document, 'concepts', operation.concept);
242
+ source = appendCollectionItem(source, 'concepts', operation.concept);
122
243
  counts.addedConcepts += 1;
123
244
  }
124
245
  else if (operation.op === 'add-relationship') {
125
- appendBlockItem(document, 'relationships', operation.relationship);
246
+ source = appendCollectionItem(source, 'relationships', operation.relationship);
126
247
  counts.addedRelationships += 1;
127
248
  }
128
249
  else {
129
250
  const collection = operation.op === 'update-concept' ? 'concepts' : 'relationships';
130
- const payload = operation.op === 'update-concept'
251
+ const payload = (operation.op === 'update-concept'
131
252
  ? operation.concept
132
- : operation.relationship;
133
- const item = findItem(document, collection, payload.id);
134
- if (item === undefined) {
253
+ : operation.relationship);
254
+ const scalars = operation.op === 'update-concept'
255
+ ? SCALAR_CONCEPT_FIELDS
256
+ : SCALAR_RELATIONSHIP_FIELDS;
257
+ const lists = operation.op === 'update-concept'
258
+ ? LIST_CONCEPT_FIELDS
259
+ : LIST_RELATIONSHIP_FIELDS;
260
+ const id = payload.id;
261
+ const removals = operation.remove ?? [];
262
+ const contradiction = removals.find((key) => payload[key] !== undefined);
263
+ if (contradiction !== undefined) {
135
264
  return failed([
136
- locate(`Operation ${index} updates "${payload.id}", which does not exist in ${operation.document}`),
265
+ locate(`Operation ${index} both sets and removes "${contradiction}" on "${id}"`),
137
266
  ]);
138
267
  }
139
- applyFields(document, item, payload, operation.op === 'update-concept'
140
- ? SCALAR_CONCEPT_FIELDS
141
- : SCALAR_RELATIONSHIP_FIELDS, operation.op === 'update-concept'
142
- ? LIST_CONCEPT_FIELDS
143
- : LIST_RELATIONSHIP_FIELDS);
268
+ const located = itemMap(source, collection, id);
269
+ if (located === undefined) {
270
+ return failed([
271
+ locate(`Operation ${index} updates "${id}", which does not exist in ${operation.document}`),
272
+ ]);
273
+ }
274
+ for (const key of scalars) {
275
+ if (payload[key] === undefined)
276
+ continue;
277
+ source = setScalarField(source, itemMap(source, collection, id).map, key, payload[key]);
278
+ }
279
+ for (const key of lists) {
280
+ const additions = payload[key];
281
+ if (additions === undefined || additions.length === 0)
282
+ continue;
283
+ source = appendListField(source, itemMap(source, collection, id).map, key, additions);
284
+ }
285
+ for (const key of removals) {
286
+ const removed = removeField(source, itemMap(source, collection, id).map, key);
287
+ if (removed === undefined) {
288
+ return failed([
289
+ locate(`Operation ${index} removes "${key}", which is not set on "${id}"`),
290
+ ]);
291
+ }
292
+ source = removed;
293
+ }
144
294
  if (operation.op === 'update-concept') {
145
295
  counts.updatedConcepts += 1;
146
296
  }
@@ -148,13 +298,10 @@ export function runApplyCommand(options, cwd) {
148
298
  counts.updatedRelationships += 1;
149
299
  }
150
300
  }
301
+ candidates.set(absolute, source);
151
302
  }
152
303
  // The atomic gate: the whole candidate workspace must compile before a
153
304
  // single byte is written; any diagnostic rejects the entire batch.
154
- const candidates = new Map([...parsed.entries()].map(([absolute, document]) => [
155
- absolute,
156
- document.toString({ lineWidth: 0 }),
157
- ]));
158
305
  const compilation = compileWorkspace([...workspace.profiles, ...workspace.documents].map((path) => {
159
306
  const absolute = resolve(cwd, path);
160
307
  return {
@@ -167,7 +314,7 @@ export function runApplyCommand(options, cwd) {
167
314
  for (const [absolute, source] of candidates) {
168
315
  writeFileSync(absolute, source, 'utf8');
169
316
  }
170
- const touched = [...parsed.keys()]
317
+ const touched = [...candidates.keys()]
171
318
  .map((absolute) => workspaceDocuments.get(absolute))
172
319
  .sort();
173
320
  if (json) {
@@ -33,6 +33,7 @@ const selectStep = (report, subjectFilter) => {
33
33
  question: question.question,
34
34
  materiality: question.materiality,
35
35
  resolution: question.resolution,
36
+ ...(question.since === undefined ? {} : { since: question.since }),
36
37
  };
37
38
  }
38
39
  const subjects = subjectFilter === undefined
@@ -49,6 +50,7 @@ const selectStep = (report, subjectFilter) => {
49
50
  question: first.question,
50
51
  materiality: question.materiality,
51
52
  resolution: question.resolution,
53
+ ...(question.since === undefined ? {} : { since: question.since }),
52
54
  subject: {
53
55
  id: first.id,
54
56
  ...(first.name === undefined ? {} : { name: first.name }),
@@ -56,6 +58,10 @@ const selectStep = (report, subjectFilter) => {
56
58
  ...(subjects.length > 1
57
59
  ? { remainingSubjects: subjects.length - 1 }
58
60
  : {}),
61
+ // The full roster sharing this question (#116): when one policy
62
+ // answer covers many subjects, the harness can collect it once
63
+ // and land one apply batch instead of interviewing N times.
64
+ openSubjects: subjects.map(({ id }) => id),
59
65
  };
60
66
  }
61
67
  }
@@ -44,6 +44,7 @@ export interface CatalogueQuestion {
44
44
  readonly materiality: string;
45
45
  readonly resolution: string;
46
46
  readonly authority: 'human' | 'agent' | 'either';
47
+ readonly since?: string;
47
48
  }
48
49
  export interface QuestionCatalogue {
49
50
  readonly format: 'yarramate/question-catalogue/v1';
@@ -74,6 +75,7 @@ interface ReportQuestion {
74
75
  readonly question: string;
75
76
  readonly materiality: string;
76
77
  readonly resolution: string;
78
+ readonly since?: string;
77
79
  readonly subjects?: readonly OpenSubject[];
78
80
  }
79
81
  export interface InterrogationReport {
@@ -175,6 +175,7 @@ export function evaluateCatalogue(catalogue, graph, profileContext) {
175
175
  question: question.question.trim(),
176
176
  materiality: question.materiality.trim(),
177
177
  resolution: question.resolution.trim(),
178
+ ...(question.since === undefined ? {} : { since: question.since }),
178
179
  };
179
180
  if (question.scope === 'workspace') {
180
181
  const isOpen = question.trigger.every((condition) => conditionHolds(index, condition, undefined, profileContext));
@@ -253,12 +254,13 @@ export function renderInterrogationReport(report) {
253
254
  lines.push(` closed ${question.id}`);
254
255
  continue;
255
256
  }
257
+ const sinceMarker = question.since === undefined ? '' : ` [since ${question.since}]`;
256
258
  if (question.subjects === undefined) {
257
- lines.push(` OPEN ${question.id} — ${question.question}`);
259
+ lines.push(` OPEN ${question.id}${sinceMarker} — ${question.question}`);
258
260
  lines.push(` why: ${question.materiality}`);
259
261
  continue;
260
262
  }
261
- lines.push(` OPEN ${question.id} (${question.subjects.length} ${question.subjects.length === 1 ? 'subject' : 'subjects'})`);
263
+ lines.push(` OPEN ${question.id}${sinceMarker} (${question.subjects.length} ${question.subjects.length === 1 ? 'subject' : 'subjects'})`);
262
264
  for (const subject of question.subjects) {
263
265
  lines.push(` ask: "${subject.question}" [authority: ${question.authority}]`);
264
266
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yarramate",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Tool-neutral semantic architecture engine and guided methodology",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -139,6 +139,17 @@
139
139
  "remainingSubjects": {
140
140
  "type": "integer",
141
141
  "minimum": 1
142
+ },
143
+ "openSubjects": {
144
+ "type": "array",
145
+ "minItems": 1,
146
+ "items": {
147
+ "type": "string",
148
+ "minLength": 1
149
+ }
150
+ },
151
+ "since": {
152
+ "type": "string"
142
153
  }
143
154
  }
144
155
  }
@@ -68,6 +68,7 @@
68
68
  "question": { "type": "string", "minLength": 1 },
69
69
  "materiality": { "type": "string", "minLength": 1 },
70
70
  "resolution": { "type": "string", "minLength": 1 },
71
+ "since": { "type": "string" },
71
72
  "subjects": {
72
73
  "type": "array",
73
74
  "minItems": 1,
@@ -264,6 +264,22 @@
264
264
  "type": "object"
265
265
  }
266
266
  ]
267
+ },
268
+ "remove": {
269
+ "type": "array",
270
+ "minItems": 1,
271
+ "items": {
272
+ "enum": [
273
+ "name",
274
+ "description",
275
+ "status",
276
+ "owner",
277
+ "constraints",
278
+ "references",
279
+ "presentIn",
280
+ "attestations"
281
+ ]
282
+ }
267
283
  }
268
284
  }
269
285
  },
@@ -294,6 +310,21 @@
294
310
  "type": "object"
295
311
  }
296
312
  ]
313
+ },
314
+ "remove": {
315
+ "type": "array",
316
+ "minItems": 1,
317
+ "items": {
318
+ "enum": [
319
+ "name",
320
+ "description",
321
+ "status",
322
+ "mode",
323
+ "content",
324
+ "references",
325
+ "presentIn"
326
+ ]
327
+ }
297
328
  }
298
329
  }
299
330
  }
@@ -392,6 +392,11 @@
392
392
  "resolution": {
393
393
  "description": "Guidance for how an accepted answer should land as native authoring (which claim, relationship, or state to add).",
394
394
  "$ref": "#/$defs/nonEmptyText"
395
+ },
396
+ "since": {
397
+ "description": "The catalogue version this question first appeared in. Lets consumers distinguish 'the catalogue deepened' from 'the model regressed' when a completed interview reopens (ADR 0063).",
398
+ "type": "string",
399
+ "pattern": "^[0-9]+\\.[0-9]+$"
395
400
  }
396
401
  },
397
402
  "allOf": [
@@ -129,7 +129,11 @@ yarramate design .yarramate/workspace.yaml
129
129
  .yarramate/workspace.yaml`), then re-run `design` — the next question is
130
130
  recomputed from the model, so the loop is resumable across sessions and
131
131
  agents with no handover. Use `--subject <id>` to focus the interview on
132
- one element. The interview is complete when `design` says so.
132
+ one element. When a step reports many `openSubjects` sharing one
133
+ question (ownership is the classic case), do not interview N times:
134
+ collect the policy answer once — "who owns what, by area" — and land it
135
+ across every listed subject as one apply batch. The interview is
136
+ complete when `design` says so.
133
137
  5. Create:
134
138
  - an alternatives projection for the decision;
135
139
  - a bounded target projection for implementation agents.