@ai-sdlc/orchestrator 0.10.0 → 0.13.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 (156) hide show
  1. package/dist/adapters.d.ts +42 -3
  2. package/dist/adapters.js +133 -3
  3. package/dist/admission-composite.d.ts +112 -1
  4. package/dist/admission-composite.js +85 -4
  5. package/dist/admission-enrichment.d.ts +12 -3
  6. package/dist/admission-enrichment.js +64 -13
  7. package/dist/admission-score.d.ts +30 -0
  8. package/dist/admission-score.js +4 -1
  9. package/dist/backlog-adapter.d.ts +45 -0
  10. package/dist/backlog-adapter.js +145 -1
  11. package/dist/calibration.d.ts +81 -0
  12. package/dist/calibration.js +76 -0
  13. package/dist/cli/commands/init-features.d.ts +301 -2
  14. package/dist/cli/commands/init-features.js +634 -7
  15. package/dist/cli/commands/init-templates.d.ts +198 -1
  16. package/dist/cli/commands/init-templates.js +943 -1
  17. package/dist/cli/commands/init.d.ts +45 -0
  18. package/dist/cli/commands/init.js +147 -5
  19. package/dist/cli/commands/run.js +9 -1
  20. package/dist/cli/index.d.ts +11 -0
  21. package/dist/cli/index.js +42 -8
  22. package/dist/compliance/composer.d.ts +79 -0
  23. package/dist/compliance/composer.js +258 -0
  24. package/dist/compliance/errors.d.ts +64 -0
  25. package/dist/compliance/errors.js +85 -0
  26. package/dist/compliance/loader.d.ts +52 -0
  27. package/dist/compliance/loader.js +124 -0
  28. package/dist/compliance/types.d.ts +184 -0
  29. package/dist/compliance/types.js +41 -0
  30. package/dist/compliance-clearance.d.ts +269 -0
  31. package/dist/compliance-clearance.js +269 -0
  32. package/dist/config.js +17 -0
  33. package/dist/cost-tracker.d.ts +22 -0
  34. package/dist/cost-tracker.js +41 -0
  35. package/dist/database/adapters/external.js +5 -1
  36. package/dist/embedding/adapters/openai-text-embedding-3-small.d.ts +71 -0
  37. package/dist/embedding/adapters/openai-text-embedding-3-small.js +190 -0
  38. package/dist/embedding/consumers/tessellation-drift.d.ts +74 -0
  39. package/dist/embedding/consumers/tessellation-drift.js +76 -0
  40. package/dist/embedding/cross-provider.d.ts +78 -0
  41. package/dist/embedding/cross-provider.js +75 -0
  42. package/dist/embedding/deprecation.d.ts +151 -0
  43. package/dist/embedding/deprecation.js +229 -0
  44. package/dist/embedding/errors.d.ts +90 -0
  45. package/dist/embedding/errors.js +150 -0
  46. package/dist/embedding/index.d.ts +29 -0
  47. package/dist/embedding/index.js +24 -0
  48. package/dist/embedding/pipeline-load.d.ts +146 -0
  49. package/dist/embedding/pipeline-load.js +178 -0
  50. package/dist/embedding/registry.d.ts +45 -0
  51. package/dist/embedding/registry.js +61 -0
  52. package/dist/embedding/stale-vector.d.ts +110 -0
  53. package/dist/embedding/stale-vector.js +92 -0
  54. package/dist/embedding/storage/index.d.ts +51 -0
  55. package/dist/embedding/storage/index.js +43 -0
  56. package/dist/embedding/storage/jsonl-backend.d.ts +150 -0
  57. package/dist/embedding/storage/jsonl-backend.js +332 -0
  58. package/dist/embedding/storage/types.d.ts +135 -0
  59. package/dist/embedding/storage/types.js +13 -0
  60. package/dist/embedding/types.d.ts +180 -0
  61. package/dist/embedding/types.js +10 -0
  62. package/dist/execute.d.ts +29 -2
  63. package/dist/execute.js +171 -30
  64. package/dist/index.d.ts +14 -2
  65. package/dist/index.js +19 -2
  66. package/dist/journey/inheritance-validator.d.ts +396 -0
  67. package/dist/journey/inheritance-validator.js +370 -0
  68. package/dist/journey/state-id-drift-rule.d.ts +137 -0
  69. package/dist/journey/state-id-drift-rule.js +245 -0
  70. package/dist/journey-sa2-router.d.ts +395 -0
  71. package/dist/journey-sa2-router.js +308 -0
  72. package/dist/runners/review-agent.js +6 -2
  73. package/dist/runners/runner-registry.d.ts +36 -0
  74. package/dist/runners/runner-registry.js +90 -0
  75. package/dist/runtime/attestations.d.ts +173 -13
  76. package/dist/runtime/attestations.js +252 -40
  77. package/dist/runtime/index.d.ts +1 -1
  78. package/dist/runtime/index.js +1 -1
  79. package/dist/sa-scoring/layer3-llm.js +6 -1
  80. package/dist/sa-scoring/revision-proposal-config.d.ts +178 -0
  81. package/dist/sa-scoring/revision-proposal-config.js +198 -0
  82. package/dist/sa-scoring/revision-proposal.d.ts +285 -0
  83. package/dist/sa-scoring/revision-proposal.js +417 -0
  84. package/dist/signal-ingestion/adapters/community-thread.d.ts +43 -0
  85. package/dist/signal-ingestion/adapters/community-thread.js +55 -0
  86. package/dist/signal-ingestion/adapters/in-app-feedback.d.ts +67 -0
  87. package/dist/signal-ingestion/adapters/in-app-feedback.js +51 -0
  88. package/dist/signal-ingestion/adapters/manual.d.ts +78 -0
  89. package/dist/signal-ingestion/adapters/manual.js +112 -0
  90. package/dist/signal-ingestion/adapters/support-ticket.d.ts +47 -0
  91. package/dist/signal-ingestion/adapters/support-ticket.js +51 -0
  92. package/dist/signal-ingestion/classifier.d.ts +205 -0
  93. package/dist/signal-ingestion/classifier.js +494 -0
  94. package/dist/signal-ingestion/clustering-types.d.ts +36 -0
  95. package/dist/signal-ingestion/clustering-types.js +14 -0
  96. package/dist/signal-ingestion/clustering.d.ts +200 -0
  97. package/dist/signal-ingestion/clustering.js +413 -0
  98. package/dist/signal-ingestion/config.d.ts +351 -0
  99. package/dist/signal-ingestion/config.js +587 -0
  100. package/dist/signal-ingestion/d1.d.ts +252 -0
  101. package/dist/signal-ingestion/d1.js +235 -0
  102. package/dist/signal-ingestion/errors.d.ts +73 -0
  103. package/dist/signal-ingestion/errors.js +108 -0
  104. package/dist/signal-ingestion/governance-events.d.ts +181 -0
  105. package/dist/signal-ingestion/governance-events.js +189 -0
  106. package/dist/signal-ingestion/index.d.ts +35 -0
  107. package/dist/signal-ingestion/index.js +53 -0
  108. package/dist/signal-ingestion/manual-share-metric.d.ts +93 -0
  109. package/dist/signal-ingestion/manual-share-metric.js +106 -0
  110. package/dist/signal-ingestion/registry.d.ts +40 -0
  111. package/dist/signal-ingestion/registry.js +137 -0
  112. package/dist/signal-ingestion/residency.d.ts +227 -0
  113. package/dist/signal-ingestion/residency.js +238 -0
  114. package/dist/signal-ingestion/significance.d.ts +554 -0
  115. package/dist/signal-ingestion/significance.js +555 -0
  116. package/dist/signal-ingestion/types.d.ts +191 -0
  117. package/dist/signal-ingestion/types.js +8 -0
  118. package/dist/substrate/drift-composition.d.ts +270 -0
  119. package/dist/substrate/drift-composition.js +306 -0
  120. package/dist/substrate/drift-tui-surface.d.ts +61 -0
  121. package/dist/substrate/drift-tui-surface.js +102 -0
  122. package/dist/substrate/identity-class.d.ts +176 -0
  123. package/dist/substrate/identity-class.js +201 -0
  124. package/dist/tessellation/cross-soul-provenance-rule.d.ts +133 -0
  125. package/dist/tessellation/cross-soul-provenance-rule.js +171 -0
  126. package/dist/tessellation/inter-soul-embedding-distance-rule.d.ts +61 -0
  127. package/dist/tessellation/inter-soul-embedding-distance-rule.js +67 -0
  128. package/dist/tessellation/rule-registry.d.ts +269 -0
  129. package/dist/tessellation/rule-registry.js +92 -0
  130. package/dist/tessellation/soul-slug-ast-scan-rule.d.ts +90 -0
  131. package/dist/tessellation/soul-slug-ast-scan-rule.js +158 -0
  132. package/dist/tessellation-admission.d.ts +162 -0
  133. package/dist/tessellation-admission.js +146 -0
  134. package/dist/tessellation-drift.d.ts +246 -0
  135. package/dist/tessellation-drift.js +250 -0
  136. package/dist/validate-config.js +13 -0
  137. package/dist/validate-issue.js +2 -2
  138. package/dist/variant/cardinality-activation.d.ts +126 -0
  139. package/dist/variant/cardinality-activation.js +101 -0
  140. package/dist/variant/deprecation-lifecycle.d.ts +184 -0
  141. package/dist/variant/deprecation-lifecycle.js +208 -0
  142. package/dist/variant/drift-extension.d.ts +136 -0
  143. package/dist/variant/drift-extension.js +164 -0
  144. package/dist/variant/engineering-review.d.ts +185 -0
  145. package/dist/variant/engineering-review.js +142 -0
  146. package/dist/variant/index.d.ts +32 -0
  147. package/dist/variant/index.js +32 -0
  148. package/dist/variant/inheritance-validator.d.ts +165 -0
  149. package/dist/variant/inheritance-validator.js +139 -0
  150. package/dist/variant/internal-adopter/index.d.ts +11 -0
  151. package/dist/variant/internal-adopter/index.js +10 -0
  152. package/dist/variant/internal-adopter/products.d.ts +156 -0
  153. package/dist/variant/internal-adopter/products.js +366 -0
  154. package/dist/variant-admission.d.ts +316 -0
  155. package/dist/variant-admission.js +247 -0
  156. package/package.json +10 -8
@@ -0,0 +1,555 @@
1
+ /**
2
+ * RFC-0030 Phase 4 — Tier 2 significance threshold, SA resonance filter,
3
+ * flooding detection (z-score on rolling per-source baseline), residency-
4
+ * violation gate.
5
+ *
6
+ * This module operates on Phase 3 `DemandCluster[]` output and produces:
7
+ *
8
+ * 1. **Tier 2 significance gate** (RFC-0030 §8): clusters must meet
9
+ * `minSignalCount` + `minUniqueSources` + `minTier1SignalCount` +
10
+ * `minClusterAgeDays` before they qualify for D1 scoring. Below-threshold
11
+ * clusters are marked `monitored` (not silently dropped — they remain
12
+ * visible for operator review).
13
+ *
14
+ * 2. **SA resonance filter** (RFC-0030 §9 + RFC-0029 Principle 4): clusters
15
+ * are bucketed by SA resonance score against the current Soul DID:
16
+ * - `>= fullWeight` → `full` (no D1 weight discount)
17
+ * - `>= discounted` → `discounted` (D1 weight × 0.7)
18
+ * - `>= excluded` (exclusive) → `low-sa-review` (D1 weight × 0.3 +
19
+ * Decision logged for Product Lead batch review per AC #3)
20
+ * - `<= excluded` (== 0.0) → `out-of-scope` (excluded from D1;
21
+ * logged as out-of-scope demand)
22
+ *
23
+ * 3. **OQ-13.5 flooding detection (AISDLC-433 v0.3 refinement)** — REPLACES
24
+ * the previous fixed-multiplier detector with z-score on the same
25
+ * per-source rolling baseline:
26
+ * - For every source in the detection window, compute its
27
+ * `windowCount` (signals in the last `windowMinutes`) and compare
28
+ * to the per-source baseline `{mean, stddev}` computed from the
29
+ * rolling `baselineDays`-day history.
30
+ * - Trigger condition (RFC-0030 §13.5):
31
+ * `windowCount > (mean + zScoreThreshold × stddev)`
32
+ * AND `uniqueSources_in_window < minUniqueSourcesForSuspicion`
33
+ * → emit `Decision: signal-flooding-detected`.
34
+ * - **Cold-start handling** (AC #4): when the rolling baseline has
35
+ * fewer than `baselineDays` of history, the detector returns the
36
+ * `calibrating` status and emits NO Decision — Tier 2 significance
37
+ * is the sole defense during the calibration window.
38
+ * - **Quarantine** (AC #6, #7): flooding signals are tagged
39
+ * `quarantined: true` with an `expiresAt` timestamp; quarantined
40
+ * signals do NOT feed D1 (D1 path filters them out). Default
41
+ * duration 24h; per-org `flooding.quarantineDurationHours`
42
+ * override. Auto-expiry releases signals back to D1 candidacy.
43
+ * - **Operator one-click unquarantine** (AC #8, #9): the surface
44
+ * composes with RFC-0023 Blockers pane via the `QuarantineStore`
45
+ * API + the `unquarantineFlooded()` helper. Unquarantine emits
46
+ * `Decision: signal-flooding-false-positive` referencing the
47
+ * original flooding Decision (feedback signal for v2
48
+ * reputation-weighting calibration).
49
+ * Pipeline NEVER halts on flooding (G0 non-blocking contract).
50
+ *
51
+ * 4. **OQ-13.3 residency-violation gate** (adapter-level): per `checkSignalResidency`,
52
+ * a signal whose `region` falls outside the adopter's declared regime
53
+ * `allowedRegions` is refused at adapter level. Pipeline never halts.
54
+ *
55
+ * @module signal-ingestion/significance
56
+ */
57
+ import { DEFAULT_SIGNAL_INGESTION_CONFIG } from './config.js';
58
+ /** Per-bucket D1 weight multiplier per RFC-0030 §9. */
59
+ export const SA_WEIGHT_MULTIPLIERS = Object.freeze({
60
+ full: 1.0,
61
+ discounted: 0.7,
62
+ 'low-sa-review': 0.3,
63
+ 'out-of-scope': 0.0,
64
+ pending: 0.0,
65
+ });
66
+ /**
67
+ * Re-export of the live default for back-compat with the previous shipped
68
+ * surface. Phase 4 originally exposed `DEFAULT_FLOODING_DETECTION_CONFIG`
69
+ * as a frozen object; AISDLC-433 routes the same name to the v0.3 z-score
70
+ * defaults (sourced from `DEFAULT_SIGNAL_INGESTION_CONFIG.flooding.detection`)
71
+ * so downstream callers continue to import a working default object.
72
+ */
73
+ export const DEFAULT_FLOODING_DETECTION_CONFIG = Object.freeze({
74
+ ...DEFAULT_SIGNAL_INGESTION_CONFIG.flooding.detection,
75
+ });
76
+ // ── Tier 2 significance gate (§8) ───────────────────────────────────────────
77
+ /**
78
+ * Apply the RFC-0030 §8 Tier 2 significance threshold to a single cluster.
79
+ *
80
+ * The four conditions are evaluated independently so reasons can be reported
81
+ * back; the cluster `qualified` IFF all four pass.
82
+ *
83
+ * `asOf` defaults to `new Date()` for cluster-age computation.
84
+ */
85
+ export function assessTier2Significance(cluster, threshold = DEFAULT_SIGNAL_INGESTION_CONFIG.tier2SignificanceThreshold, asOf = new Date()) {
86
+ const reasons = {
87
+ signalCount: cluster.signalCount < threshold.minSignalCount,
88
+ uniqueSources: cluster.uniqueSources < threshold.minUniqueSources,
89
+ tier1SignalCount: cluster.tier1SignalCount < threshold.minTier1SignalCount,
90
+ clusterAgeDays: clusterAgeDays(cluster, asOf) < threshold.minClusterAgeDays,
91
+ };
92
+ const qualified = !reasons.signalCount &&
93
+ !reasons.uniqueSources &&
94
+ !reasons.tier1SignalCount &&
95
+ !reasons.clusterAgeDays;
96
+ return { state: qualified ? 'qualified' : 'monitored', reasons };
97
+ }
98
+ /** Days elapsed between cluster.oldestSignalAt and `asOf`. */
99
+ function clusterAgeDays(cluster, asOf) {
100
+ const ageMs = asOf.getTime() - cluster.oldestSignalAt.getTime();
101
+ return ageMs / (1000 * 60 * 60 * 24);
102
+ }
103
+ // ── SA resonance filter (§9) ────────────────────────────────────────────────
104
+ /**
105
+ * Classify a cluster's SA resonance into one of five buckets per RFC-0030 §9.
106
+ *
107
+ * When `cluster.saResonance` is `undefined`, returns `pending` — the caller
108
+ * is expected to populate the field via the Soul DID adapter before
109
+ * invocation. Fail-closed: `pending` results in `eligibleForD1 = false`.
110
+ */
111
+ export function classifySaResonance(cluster, thresholds = DEFAULT_SIGNAL_INGESTION_CONFIG.saResonanceThresholds) {
112
+ if (cluster.saResonance === undefined)
113
+ return 'pending';
114
+ const sa = cluster.saResonance;
115
+ if (sa >= thresholds.fullWeight)
116
+ return 'full';
117
+ if (sa >= thresholds.discounted)
118
+ return 'discounted';
119
+ if (sa > thresholds.excluded)
120
+ return 'low-sa-review';
121
+ return 'out-of-scope';
122
+ }
123
+ /**
124
+ * Apply Phase 4 significance + SA filter to a batch of `DemandCluster`s.
125
+ *
126
+ * The resulting `assessments` carry the final `d1WeightMultiplier` that
127
+ * Phase 5 multiplies into the D1 formula. Below-threshold clusters retain
128
+ * `eligibleForD1 = false` (not silently dropped) per RFC-0030 §8.
129
+ *
130
+ * `lowSaDecisions` are emitted for every cluster whose SA bucket is
131
+ * `low-sa-review` — these are the AC #3 "low-SA-but-high-volume" Decisions.
132
+ */
133
+ export function assessClusterSignificance(clusters, options = {}) {
134
+ const config = options.config ?? DEFAULT_SIGNAL_INGESTION_CONFIG;
135
+ const asOf = options.asOf ?? new Date();
136
+ const assessments = [];
137
+ const lowSaDecisions = [];
138
+ const outOfScopeDecisions = [];
139
+ for (const cluster of clusters) {
140
+ const { state: tier2Significance, reasons: tier2Reasons } = assessTier2Significance(cluster, config.tier2SignificanceThreshold, asOf);
141
+ const saResonanceBucket = classifySaResonance(cluster, config.saResonanceThresholds);
142
+ const significanceMultiplier = tier2Significance === 'qualified' ? 1.0 : 0.0;
143
+ const saMultiplier = SA_WEIGHT_MULTIPLIERS[saResonanceBucket];
144
+ const d1WeightMultiplier = significanceMultiplier * saMultiplier;
145
+ const eligibleForD1 = tier2Significance === 'qualified' &&
146
+ saResonanceBucket !== 'out-of-scope' &&
147
+ saResonanceBucket !== 'pending';
148
+ assessments.push({
149
+ cluster,
150
+ tier2Significance,
151
+ tier2Reasons,
152
+ saResonanceBucket,
153
+ eligibleForD1,
154
+ d1WeightMultiplier,
155
+ });
156
+ // AC #3 — low-SA demand surfaces via Decision for Product Lead review.
157
+ // Emitted even when the cluster is `monitored` (below significance) — the
158
+ // operator should see that low-SA demand IS accumulating, regardless of
159
+ // whether the Tier 2 threshold has been met.
160
+ if (saResonanceBucket === 'low-sa-review' && cluster.saResonance !== undefined) {
161
+ lowSaDecisions.push({
162
+ type: 'Decision',
163
+ decision: 'signal-low-sa-for-review',
164
+ clusterId: cluster.clusterId,
165
+ saResonance: cluster.saResonance,
166
+ signalCount: cluster.signalCount,
167
+ message: `Cluster ${cluster.clusterId} has SA resonance ${cluster.saResonance.toFixed(3)} ` +
168
+ `(below 'discounted' threshold ${config.saResonanceThresholds.discounted.toFixed(2)}); ` +
169
+ `flagging for Product Lead batch review.`,
170
+ });
171
+ }
172
+ if (saResonanceBucket === 'out-of-scope' && cluster.saResonance !== undefined) {
173
+ outOfScopeDecisions.push({
174
+ type: 'Decision',
175
+ decision: 'signal-out-of-scope',
176
+ clusterId: cluster.clusterId,
177
+ saResonance: cluster.saResonance,
178
+ signalCount: cluster.signalCount,
179
+ message: `Cluster ${cluster.clusterId} has SA resonance ${cluster.saResonance.toFixed(3)} ` +
180
+ `(at or below 'excluded' threshold ${config.saResonanceThresholds.excluded.toFixed(2)}); ` +
181
+ `logging as out-of-scope demand for separate triage.`,
182
+ });
183
+ }
184
+ }
185
+ return { assessments, lowSaDecisions, outOfScopeDecisions };
186
+ }
187
+ /**
188
+ * Z-score detector — RFC-0030 §13.5 v0.3 trigger condition is
189
+ * `windowCount > (mean + zScoreThreshold × stddev) AND uniqueSources_in_window
190
+ * < minUniqueSourcesForSuspicion`.
191
+ *
192
+ * Returns a `FloodingDetectionResult` whose `status` discriminates the four
193
+ * outcomes (flooded / clean / calibrating / empty-window). The caller routes
194
+ * `result.decision` to the catalog when `status === 'flooded'`.
195
+ *
196
+ * Cold-start (AC #4): when the maximum per-source `baselineDays` observed
197
+ * across all in-window sources is < `config.baselineDays`, the detector
198
+ * returns `status: 'calibrating'` with no Decision — Tier 2 significance is
199
+ * the sole defense until the rolling baseline has built up.
200
+ *
201
+ * Quarantine (AC #6, #7): when `quarantineConfig.enabled`, every in-window
202
+ * signal from a flagged source is tagged in the supplied `quarantineStore`
203
+ * with `expiresAt = asOf + quarantineDurationHours`. Auto-expiry happens
204
+ * lazily on read (see `isSignalQuarantined()`).
205
+ */
206
+ export function detectFlooding(signals, options = {}) {
207
+ const config = options.config ?? DEFAULT_FLOODING_DETECTION_CONFIG;
208
+ const quarantineConfig = options.quarantineConfig ?? DEFAULT_SIGNAL_INGESTION_CONFIG.flooding.quarantine;
209
+ const asOf = options.asOf ?? new Date();
210
+ const perSourceBaselines = options.perSourceBaselines ?? {};
211
+ const generateDecisionId = options.generateDecisionId ?? defaultDecisionIdFactory(asOf);
212
+ const windowStartMs = asOf.getTime() - config.windowMinutes * 60 * 1000;
213
+ const windowEndMs = asOf.getTime();
214
+ const windowSignals = signals.filter((s) => {
215
+ const ts = s.sourceTimestamp.getTime();
216
+ return ts >= windowStartMs && ts <= windowEndMs;
217
+ });
218
+ if (windowSignals.length === 0) {
219
+ return { status: 'empty-window', signalCount: 0, uniqueSources: 0, baselineDaysObserved: 0 };
220
+ }
221
+ // Group window signals by source.
222
+ const perSourceWindowCounts = new Map();
223
+ const perSourceWindowSignals = new Map();
224
+ for (const s of windowSignals) {
225
+ const src = resolveSourceName(s);
226
+ perSourceWindowCounts.set(src, (perSourceWindowCounts.get(src) ?? 0) + 1);
227
+ const bucket = perSourceWindowSignals.get(src) ?? [];
228
+ bucket.push(s);
229
+ perSourceWindowSignals.set(src, bucket);
230
+ }
231
+ const signalCount = windowSignals.length;
232
+ const uniqueSources = perSourceWindowCounts.size;
233
+ // Cold-start: take max baselineDays across all in-window sources. When ANY
234
+ // source has the full window observed, the detector is calibrated for that
235
+ // source; when NO source has the full window, we're calibrating overall.
236
+ let maxBaselineDays = 0;
237
+ for (const src of perSourceWindowCounts.keys()) {
238
+ const samples = perSourceBaselines[src] ?? [];
239
+ if (samples.length > maxBaselineDays)
240
+ maxBaselineDays = samples.length;
241
+ }
242
+ if (maxBaselineDays < config.baselineDays) {
243
+ return {
244
+ status: 'calibrating',
245
+ signalCount,
246
+ uniqueSources,
247
+ baselineDaysObserved: maxBaselineDays,
248
+ };
249
+ }
250
+ // Trigger condition guard #2: uniqueSources_in_window < minUniqueSourcesForSuspicion.
251
+ // When uniqueSources >= threshold, a "many sources contributing" pattern is
252
+ // healthy organic traffic — z-score alone is not enough to call it flooding.
253
+ // RFC-0030 §13.5: "trigger when volume > 3σ AND uniqueSources < 3".
254
+ if (uniqueSources >= config.minUniqueSourcesForSuspicion) {
255
+ return {
256
+ status: 'clean',
257
+ signalCount,
258
+ uniqueSources,
259
+ baselineDaysObserved: maxBaselineDays,
260
+ };
261
+ }
262
+ // Per-source z-score check.
263
+ const flaggedSources = [];
264
+ for (const [src, windowCount] of perSourceWindowCounts) {
265
+ const samples = perSourceBaselines[src] ?? [];
266
+ if (samples.length < config.baselineDays)
267
+ continue; // skip cold per-source
268
+ const stats = computeBaselineStat(samples);
269
+ const zScore = computeZScore(windowCount, stats);
270
+ if (zScore > config.zScoreThreshold) {
271
+ flaggedSources.push({
272
+ sourceId: src,
273
+ zScore,
274
+ windowCount,
275
+ baselineMean: stats.mean,
276
+ baselineStddev: stats.stddev,
277
+ baselineDays: stats.sampleCount,
278
+ });
279
+ }
280
+ }
281
+ if (flaggedSources.length === 0) {
282
+ return {
283
+ status: 'clean',
284
+ signalCount,
285
+ uniqueSources,
286
+ baselineDaysObserved: maxBaselineDays,
287
+ };
288
+ }
289
+ // Build Decision + apply quarantine.
290
+ const decisionId = generateDecisionId();
291
+ const detectedAt = asOf.toISOString();
292
+ const quarantinedSourceIds = [];
293
+ const quarantineDurationHours = quarantineConfig.enabled ? quarantineConfig.durationHours : 0;
294
+ if (quarantineConfig.enabled) {
295
+ const expiresAt = new Date(asOf.getTime() + quarantineDurationHours * 60 * 60 * 1000);
296
+ for (const flag of flaggedSources) {
297
+ quarantinedSourceIds.push(flag.sourceId);
298
+ const sourceSignals = perSourceWindowSignals.get(flag.sourceId) ?? [];
299
+ if (options.quarantineStore !== undefined) {
300
+ for (const sig of sourceSignals) {
301
+ options.quarantineStore.quarantine({
302
+ sourceId: sig.sourceId,
303
+ adapterSource: flag.sourceId,
304
+ decisionId,
305
+ quarantinedAt: asOf,
306
+ expiresAt,
307
+ reason: `z-score ${flag.zScore.toFixed(2)}σ (baseline mean=${flag.baselineMean.toFixed(2)}, stddev=${flag.baselineStddev.toFixed(2)})`,
308
+ });
309
+ }
310
+ }
311
+ }
312
+ }
313
+ const decision = {
314
+ type: 'Decision',
315
+ decision: 'signal-flooding-detected',
316
+ decisionId,
317
+ detectedAt,
318
+ signalCount,
319
+ uniqueSources,
320
+ flaggedSources,
321
+ quarantinedSourceIds,
322
+ quarantineDurationHours,
323
+ message: floodingMessage(flaggedSources, signalCount, uniqueSources, quarantineDurationHours),
324
+ };
325
+ return {
326
+ status: 'flooded',
327
+ decision,
328
+ signalCount,
329
+ uniqueSources,
330
+ baselineDaysObserved: maxBaselineDays,
331
+ };
332
+ }
333
+ /**
334
+ * Population-standard-deviation baseline stat. Exposed for unit testing.
335
+ * Returns `{mean: 0, stddev: 0, sampleCount: 0}` on empty input — caller's
336
+ * cold-start gate should prevent that from reaching `computeZScore()`.
337
+ */
338
+ export function computeBaselineStat(samples) {
339
+ if (samples.length === 0)
340
+ return { mean: 0, stddev: 0, sampleCount: 0 };
341
+ const sum = samples.reduce((acc, n) => acc + n, 0);
342
+ const mean = sum / samples.length;
343
+ const sqSum = samples.reduce((acc, n) => acc + (n - mean) ** 2, 0);
344
+ const stddev = Math.sqrt(sqSum / samples.length);
345
+ return { mean, stddev, sampleCount: samples.length };
346
+ }
347
+ /**
348
+ * Z-score of a single observation against `BaselineStat`. When `stddev === 0`
349
+ * (degenerate baseline — every sample identical), returns `+Infinity` if the
350
+ * observation exceeds the mean, `0` if it equals the mean — matches the
351
+ * statistical convention that any deviation from a zero-variance baseline is
352
+ * infinitely surprising. Exposed for unit testing.
353
+ */
354
+ export function computeZScore(observation, stats) {
355
+ if (stats.stddev === 0) {
356
+ if (observation > stats.mean)
357
+ return Number.POSITIVE_INFINITY;
358
+ return 0;
359
+ }
360
+ return (observation - stats.mean) / stats.stddev;
361
+ }
362
+ function floodingMessage(flaggedSources, signalCount, uniqueSources, quarantineDurationHours) {
363
+ const flaggedNames = flaggedSources.map((f) => `${f.sourceId} (z=${f.zScore.toFixed(2)}σ)`);
364
+ const quarantinePart = quarantineDurationHours > 0
365
+ ? ` Quarantine applied for ${quarantineDurationHours}h.`
366
+ : ' Quarantine disabled — signals stay live.';
367
+ return (`Flooding detected: ${signalCount} signals over ${uniqueSources} sources in the detection window; ` +
368
+ `flagged sources [${flaggedNames.join(', ')}].${quarantinePart}`);
369
+ }
370
+ /**
371
+ * Resolve a source identifier for a signal — prefers `metadata.adapterName`,
372
+ * falls back to a `sourceId` prefix (the segment before the first `-`).
373
+ */
374
+ function resolveSourceName(signal) {
375
+ const fromMeta = signal.metadata?.['adapterName'];
376
+ if (typeof fromMeta === 'string' && fromMeta.length > 0)
377
+ return fromMeta;
378
+ const dashIdx = signal.sourceId.indexOf('-');
379
+ if (dashIdx > 0)
380
+ return signal.sourceId.slice(0, dashIdx);
381
+ return signal.sourceId;
382
+ }
383
+ function defaultDecisionIdFactory(asOf) {
384
+ let counter = 0;
385
+ return () => {
386
+ counter += 1;
387
+ const suffix = counter > 1 ? `-${counter}` : '';
388
+ return `flooding-${asOf.toISOString().replace(/[:.]/g, '')}${suffix}`;
389
+ };
390
+ }
391
+ /**
392
+ * Default in-memory store. Tests use this directly; production callers should
393
+ * implement a persistent variant satisfying the same interface.
394
+ */
395
+ export class InMemoryQuarantineStore {
396
+ entries = [];
397
+ released = new Set();
398
+ quarantine(entry) {
399
+ this.entries.push(entry);
400
+ }
401
+ isQuarantined(sourceId, asOf = new Date()) {
402
+ const ms = asOf.getTime();
403
+ for (const e of this.entries) {
404
+ if (e.sourceId !== sourceId)
405
+ continue;
406
+ if (this.released.has(e.decisionId))
407
+ continue;
408
+ if (e.expiresAt.getTime() <= ms)
409
+ continue;
410
+ return true;
411
+ }
412
+ return false;
413
+ }
414
+ getActiveEntries(asOf = new Date()) {
415
+ const ms = asOf.getTime();
416
+ return this.entries.filter((e) => !this.released.has(e.decisionId) && e.expiresAt.getTime() > ms);
417
+ }
418
+ getEntryByDecisionId(decisionId) {
419
+ return this.entries.filter((e) => e.decisionId === decisionId);
420
+ }
421
+ release(decisionId, _releasedAt = new Date()) {
422
+ const matched = this.getEntryByDecisionId(decisionId);
423
+ if (matched.length === 0)
424
+ return [];
425
+ this.released.add(decisionId);
426
+ return matched;
427
+ }
428
+ }
429
+ /**
430
+ * Convenience: check whether a single `RawSignal` is currently quarantined by
431
+ * looking up its `sourceId` in the store. The D1 path calls this when
432
+ * computing `eligibleForD1` — quarantined signals are excluded from the
433
+ * D1(cluster) formula per AC #6.
434
+ *
435
+ * Returns `false` when no store is supplied (back-compat — pre-AISDLC-433
436
+ * D1 paths didn't have a quarantine layer to consult).
437
+ */
438
+ export function isSignalQuarantined(signal, store, asOf = new Date()) {
439
+ if (store === undefined)
440
+ return false;
441
+ return store.isQuarantined(signal.sourceId, asOf);
442
+ }
443
+ /**
444
+ * Operator one-click unquarantine — releases every signal entry tagged with
445
+ * `originalDecisionId` and emits the `signal-flooding-false-positive`
446
+ * Decision per RFC-0030 §13.5 + AC #8 + AC #9.
447
+ *
448
+ * Returns `null` when no active entries match the Decision ID (idempotent —
449
+ * the operator's second click on the same row is a no-op + null result).
450
+ */
451
+ export function unquarantineFlooded(options) {
452
+ const asOf = options.asOf ?? new Date();
453
+ const generateDecisionId = options.generateDecisionId ?? falsePositiveDecisionIdFactory(asOf, options.originalDecisionId);
454
+ const released = options.store.release(options.originalDecisionId, asOf);
455
+ if (released.length === 0)
456
+ return null;
457
+ const releasedSourceIds = Array.from(new Set(released.map((e) => e.sourceId)));
458
+ const decisionId = generateDecisionId();
459
+ return {
460
+ type: 'Decision',
461
+ decision: 'signal-flooding-false-positive',
462
+ decisionId,
463
+ originalDecisionId: options.originalDecisionId,
464
+ releasedAt: asOf.toISOString(),
465
+ releasedSourceIds,
466
+ operatorNote: options.operatorNote,
467
+ message: `Operator marked flooding Decision ${options.originalDecisionId} as false-positive; ` +
468
+ `released ${releasedSourceIds.length} source(s) back to D1 candidacy. ` +
469
+ `v2 reputation-weighting layer will use this Decision as calibration signal.`,
470
+ };
471
+ }
472
+ function falsePositiveDecisionIdFactory(asOf, originalDecisionId) {
473
+ return () => `flooding-fp-${asOf.toISOString().replace(/[:.]/g, '')}-${originalDecisionId.slice(-8)}`;
474
+ }
475
+ /**
476
+ * Check a single signal against the adopter's declared residency regimes.
477
+ *
478
+ * **Behaviour**:
479
+ * - When `declaration.regimes` is empty → signal is permitted (no regime
480
+ * constraints declared). Adopters not declaring a regime are not subject
481
+ * to residency gating.
482
+ * - When `signal.region` is `undefined` AND at least one regime is active →
483
+ * signal is permitted (the adapter didn't surface region metadata, which
484
+ * is treated as "not subject to gating" rather than "fails the gate" to
485
+ * avoid false-positives on adapters that don't yet plumb region — a
486
+ * visible-gap metric for the operator's regime config rollout).
487
+ * - When `signal.region` is present AND at least one active regime's
488
+ * `allowedRegions` does NOT include it → signal is REFUSED and the
489
+ * `Decision` records every regime that rejected the signal.
490
+ *
491
+ * The adapter SHOULD NOT call `fetchSignals` for refused signals — the
492
+ * return-value pattern lets the adapter short-circuit per-signal.
493
+ */
494
+ export function checkSignalResidency(signal, declaration, adapterName) {
495
+ if (declaration.regimes.length === 0)
496
+ return { permitted: true };
497
+ if (signal.region === undefined)
498
+ return { permitted: true };
499
+ const region = signal.region.toLowerCase();
500
+ const violatedRegimes = [];
501
+ const allowedAcrossAllRegimes = new Set();
502
+ for (const regime of declaration.regimes) {
503
+ const allowed = declaration.allowedRegionsByRegime[regime] ?? [];
504
+ if (allowed.length === 0) {
505
+ // Active regime with no allowed regions = all signals violate.
506
+ violatedRegimes.push(regime);
507
+ continue;
508
+ }
509
+ const allowedLower = allowed.map((r) => r.toLowerCase());
510
+ for (const r of allowedLower)
511
+ allowedAcrossAllRegimes.add(r);
512
+ if (!allowedLower.includes(region)) {
513
+ violatedRegimes.push(regime);
514
+ }
515
+ }
516
+ if (violatedRegimes.length === 0)
517
+ return { permitted: true };
518
+ return {
519
+ permitted: false,
520
+ decision: {
521
+ type: 'Decision',
522
+ decision: 'signal-residency-violation',
523
+ adapter: adapterName,
524
+ sourceId: signal.sourceId,
525
+ signalRegion: signal.region,
526
+ violatedRegimes,
527
+ allowedRegions: Array.from(allowedAcrossAllRegimes).sort(),
528
+ message: `Signal ${signal.sourceId} from adapter '${adapterName}' has region '${signal.region}' ` +
529
+ `which violates the residency constraint(s) of active regime(s) [${violatedRegimes.join(', ')}]; ` +
530
+ `signal refused. Emit clarification task to update compliance.yaml regimeOverrides if ` +
531
+ `the regime declaration is incorrect, or drop the source if non-compliant.`,
532
+ },
533
+ };
534
+ }
535
+ /**
536
+ * Convenience wrapper: filter a batch of signals against the residency gate,
537
+ * returning the (possibly empty) list of permitted signals + the list of
538
+ * Decision records for refused signals. Adapter implementations can call this
539
+ * in their `fetchSignals()` body to enforce residency before returning.
540
+ */
541
+ export function filterSignalsByResidency(signals, declaration, adapterName) {
542
+ const permitted = [];
543
+ const decisions = [];
544
+ for (const s of signals) {
545
+ const result = checkSignalResidency(s, declaration, adapterName);
546
+ if (result.permitted) {
547
+ permitted.push(s);
548
+ }
549
+ else {
550
+ decisions.push(result.decision);
551
+ }
552
+ }
553
+ return { permitted, decisions };
554
+ }
555
+ //# sourceMappingURL=significance.js.map