yarramate 1.13.1 → 1.14.1

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,6 +1,6 @@
1
1
  format: yarramate/question-catalogue/v1
2
2
  id: core-enrichment
3
- version: "1.3"
3
+ version: "2.0"
4
4
  profile: yarramate/core@0.1
5
5
  presentation:
6
6
  title: Core enrichment interview
@@ -36,21 +36,35 @@ waves:
36
36
  name: Application
37
37
  description: >-
38
38
  How declared services are realized, performed, and fed with
39
- information.
39
+ information. Waits for a declared service, because that is what this
40
+ wave asks about the realization of. The kinds here are exactly the ones
41
+ `no-service-declared` asks for, so this wave opens precisely when that
42
+ question closes.
40
43
  opensWhen:
41
- - condition: has-any-subject
44
+ - condition: has-subject-of-kind
45
+ kinds:
46
+ - yarramate/core@0.1#businessService
47
+ - yarramate/core@0.1#applicationService
48
+ - yarramate/core@0.1#technologyService
42
49
  - id: technology
43
50
  name: Technology
44
51
  description: >-
45
52
  Where the declared applications actually run and what materializes
46
- them.
53
+ them. Waits for a declared application component, because asking
54
+ where something runs before anything is declared to run is a question
55
+ with no subject.
47
56
  opensWhen:
48
- - condition: has-any-subject
57
+ - condition: has-subject-of-kind
58
+ kinds:
59
+ - yarramate/core@0.1#applicationComponent
49
60
  - id: implementation
50
61
  name: Implementation
51
62
  description: >-
52
63
  How the planned architecture becomes real: work, deliverables, and
53
- the plateaus between here and there.
64
+ the plateaus between here and there. NOT phase-gated, deliberately:
65
+ every candidate gate names something this wave exists to elicit, and
66
+ a declared state compiles to a plateau, which closes this wave's own
67
+ lead question. See ADR 0136.
54
68
  opensWhen:
55
69
  - condition: has-any-subject
56
70
  - id: hygiene
@@ -918,6 +932,26 @@ questions:
918
932
  seven usually carries the whole conversation — and relate each to
919
933
  the services or components that realize it with realization.
920
934
 
935
+ - id: no-component-declared
936
+ wave: business
937
+ since: "2.0"
938
+ scope: workspace
939
+ trigger:
940
+ - condition: no-subject-of-kind
941
+ kinds:
942
+ - yarramate/core@0.1#applicationComponent
943
+ question: >-
944
+ What software actually implements these services?
945
+ askPlain: >-
946
+ In plain terms: which applications or systems does this run on?
947
+ materiality: >-
948
+ A model with services and nothing implementing them cannot say where
949
+ anything runs, so the whole technology conversation has no subject.
950
+ This question exists to keep that conversation reachable: the
951
+ technology wave opens exactly when this question closes (ADR 0136).
952
+ authority: human
953
+ resolution: >-
954
+ Add the application components that implement the declared services.
921
955
  - id: no-contract-declared
922
956
  wave: business
923
957
  since: "1.2"
@@ -62,6 +62,34 @@ export type CatalogueCondition = {
62
62
  readonly condition: 'has-subject-of-kind';
63
63
  readonly kinds: readonly string[];
64
64
  readonly kindMatching?: 'exact' | 'descendants';
65
+ } | {
66
+ /**
67
+ * The cardinality member of the same family, workspace-scope like the
68
+ * other two (#411). Fires while FEWER than `atLeast` subjects of the
69
+ * named kinds exist, so `no-subject-of-kind` is its `atLeast: 1` case.
70
+ *
71
+ * It exists because a **vocabulary question** — "which data sensitivity
72
+ * classes does this platform recognise?" — had no way to say it wanted
73
+ * more than one term. `no-subject-of-kind` closes on the first
74
+ * instance, so a one-term vocabulary was indistinguishable from a
75
+ * complete one, and an adopter measured two live cases where a class
76
+ * authored incidentally to answer a different question closed the
77
+ * vocabulary question before it was ever asked.
78
+ *
79
+ * `atLeast` is required and must be at least 2 (`YM918`): 1 is
80
+ * `no-subject-of-kind` spelled a second way, and 0 is a condition that
81
+ * can never fire, which is what `YM914` exists to refuse.
82
+ *
83
+ * Deliberately NOT a general numeric comparison. A kind's population
84
+ * against a floor is the whole need, and the narrow condition is the
85
+ * same trade that kept `has-subject-of-kind` a twin rather than a
86
+ * predicate: a vocabulary of parameterised comparisons is a query
87
+ * language, which this design declines.
88
+ */
89
+ readonly condition: 'below-subject-count';
90
+ readonly kinds: readonly string[];
91
+ readonly atLeast: number;
92
+ readonly kindMatching?: 'exact' | 'descendants';
65
93
  } | {
66
94
  readonly condition: 'no-state-defined';
67
95
  } | {
@@ -200,6 +200,7 @@ const namedKinds = (question) => {
200
200
  case 'missing-relationship':
201
201
  case 'no-subject-of-kind':
202
202
  case 'has-subject-of-kind':
203
+ case 'below-subject-count':
203
204
  case 'missing-constraint':
204
205
  kinds.push(...condition.kinds);
205
206
  break;
@@ -263,6 +264,7 @@ const CONDITION_SCOPE = {
263
264
  'has-any-subject': 'workspace',
264
265
  'no-subject-of-kind': 'workspace',
265
266
  'has-subject-of-kind': 'workspace',
267
+ 'below-subject-count': 'workspace',
266
268
  'no-state-defined': 'workspace',
267
269
  'exists-linkage': 'workspace',
268
270
  'missing-claim': 'subject',
@@ -377,6 +379,27 @@ const conditionHolds = (index, condition, subjectId, profileContext, evidence, m
377
379
  const matching = condition.kindMatching ?? 'descendants';
378
380
  return [...index.concepts].some((id) => kindMatches(index.kindOf.get(id), condition.kinds, matching, profileContext));
379
381
  }
382
+ case 'below-subject-count': {
383
+ // Counts, then compares. Written as its own count rather than as
384
+ // `!has-subject-of-kind` plus arithmetic so the degenerate reading
385
+ // stays visible: at `atLeast: 1` this IS `no-subject-of-kind`, which
386
+ // is why the loader refuses that spelling rather than quietly
387
+ // accepting two names for one condition.
388
+ //
389
+ // Stops at the threshold instead of counting the whole workspace: the
390
+ // answer is a comparison, not a tally, and a model with ten thousand
391
+ // subjects of a kind should cost the same as one with two.
392
+ const matching = condition.kindMatching ?? 'descendants';
393
+ let seen = 0;
394
+ for (const id of index.concepts) {
395
+ if (kindMatches(index.kindOf.get(id), condition.kinds, matching, profileContext)) {
396
+ seen += 1;
397
+ if (seen >= condition.atLeast)
398
+ return false;
399
+ }
400
+ }
401
+ return true;
402
+ }
380
403
  case 'no-state-defined':
381
404
  return !index.hasStates;
382
405
  case 'missing-linkage':
@@ -633,6 +656,59 @@ patternMemberships) {
633
656
  * wave never opens and carries no questions at all - one typo silently
634
657
  * retiring a wave (#351).
635
658
  */
659
+ /**
660
+ * Every condition the catalogue holds, gate and trigger alike, with the path
661
+ * that locates it. `kindReferencesOf` walks the same two places for kinds;
662
+ * this walks them for the conditions themselves, so a check about a
663
+ * condition's own shape does not have to re-derive where conditions live.
664
+ */
665
+ const conditionsOf = (catalogue) => {
666
+ const found = [];
667
+ catalogue.waves.forEach((wave, waveIndex) => {
668
+ ;
669
+ (wave.opensWhen ?? []).forEach((condition, conditionIndex) => {
670
+ found.push({
671
+ condition,
672
+ path: ['waves', waveIndex, 'opensWhen', conditionIndex],
673
+ });
674
+ });
675
+ });
676
+ catalogue.questions.forEach((question, questionIndex) => {
677
+ question.trigger.forEach((condition, conditionIndex) => {
678
+ found.push({
679
+ condition,
680
+ path: ['questions', questionIndex, 'trigger', conditionIndex],
681
+ });
682
+ });
683
+ });
684
+ return found;
685
+ };
686
+ /**
687
+ * `YM918`: `below-subject-count` must ask for at least two (#411).
688
+ *
689
+ * `atLeast: 1` is `no-subject-of-kind` under a second name, and this design
690
+ * refuses second spellings of one meaning wherever it finds them. `atLeast: 0`
691
+ * is worse: the count can never be below zero, so the question can never fire,
692
+ * which is what `YM914` refuses from a different cause.
693
+ *
694
+ * The message names `no-subject-of-kind` rather than stating a bound, because
695
+ * an author who wrote `atLeast: 1` did not make an arithmetic mistake. They
696
+ * wanted the condition that already exists.
697
+ */
698
+ const subjectCountFloorDiagnostics = ({ catalogue, locate, }) => conditionsOf(catalogue).flatMap(({ condition, path }) => condition.condition === 'below-subject-count' && condition.atLeast < 2
699
+ ? [
700
+ {
701
+ severity: 'error',
702
+ code: 'YM918',
703
+ message: `Condition "below-subject-count" asks for atLeast ${condition.atLeast}, ` +
704
+ (condition.atLeast === 1
705
+ ? 'which is what "no-subject-of-kind" already asks. Use that condition instead.'
706
+ : 'so a count could never fall below it and the question could never fire. ' +
707
+ 'Ask for at least 2, or use "no-subject-of-kind" for the presence case.'),
708
+ ...locate([...path, 'atLeast']),
709
+ },
710
+ ]
711
+ : []);
636
712
  const kindReferencesOf = (catalogue) => {
637
713
  const found = [];
638
714
  const fromCondition = (condition, path) => {
@@ -1037,6 +1113,7 @@ export function composeCatalogues(sources, profileContext) {
1037
1113
  const crossDiagnostics = documents.flatMap((document) => [
1038
1114
  ...undeclaredWaveDiagnostics(document, declaredWaves),
1039
1115
  ...gateScopeDiagnostics(document),
1116
+ ...subjectCountFloorDiagnostics(document),
1040
1117
  ...unresolvableKindDiagnostics(document, profileContext),
1041
1118
  ...unauthorableOfferDiagnostics(document, profileContext),
1042
1119
  ]);
@@ -1078,6 +1155,12 @@ export function loadQuestionCatalogue(catalogueSource, profileContext) {
1078
1155
  if (scopeDiagnostics.length > 0) {
1079
1156
  return { ok: false, diagnostics: scopeDiagnostics };
1080
1157
  }
1158
+ // Beside the scope check and for the same reason: a threshold below 2 is
1159
+ // wrong on the condition's own terms, with no profile context needed.
1160
+ const floorDiagnostics = subjectCountFloorDiagnostics(loaded.document);
1161
+ if (floorDiagnostics.length > 0) {
1162
+ return { ok: false, diagnostics: floorDiagnostics };
1163
+ }
1081
1164
  const kindDiagnostics = unresolvableKindDiagnostics(loaded.document, profileContext);
1082
1165
  if (kindDiagnostics.length > 0) {
1083
1166
  return { ok: false, diagnostics: kindDiagnostics };