pi-mega-compact 0.20.16 → 0.20.18

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 (93) hide show
  1. package/dist/config/vector-cortex.js +14 -0
  2. package/dist/config.js +1 -1
  3. package/dist/extensions/dashboard-server/api-contracts/vector-cortex-outcomes.js +13 -0
  4. package/dist/extensions/dashboard-server/route-dispatch.js +6 -0
  5. package/dist/extensions/dashboard-server/routes-rag-settings-vector-cortex.js +1 -0
  6. package/dist/extensions/dashboard-server/routes-vector-cortex-outcomes.js +79 -0
  7. package/dist/extensions/mega-dashboard.js +29 -9
  8. package/dist/src/config/vector-cortex.js +14 -0
  9. package/dist/src/config.js +1 -1
  10. package/dist/src/vector-cortex/controller/policy.js +158 -0
  11. package/dist/src/vector-cortex/controller/shadow.js +117 -0
  12. package/dist/src/vector-cortex/controller/types.js +85 -0
  13. package/dist/src/vector-cortex/outcomes/consent.js +69 -0
  14. package/dist/src/vector-cortex/outcomes/dataset.js +105 -0
  15. package/dist/src/vector-cortex/outcomes/emit.js +52 -0
  16. package/dist/src/vector-cortex/outcomes/ledger.js +77 -0
  17. package/dist/src/vector-cortex/outcomes/types.js +34 -0
  18. package/dist/vector-cortex/outcomes/consent.js +69 -0
  19. package/dist/vector-cortex/outcomes/dataset.js +105 -0
  20. package/dist/vector-cortex/outcomes/emit.js +52 -0
  21. package/dist/vector-cortex/outcomes/ledger.js +77 -0
  22. package/dist/vector-cortex/outcomes/types.js +34 -0
  23. package/extensions/dashboard-client/dist/assets/{AreaChart-B_FKg8xJ.js → AreaChart-BOGp2sag.js} +2 -2
  24. package/extensions/dashboard-client/dist/assets/{AreaChart-B_FKg8xJ.js.map → AreaChart-BOGp2sag.js.map} +1 -1
  25. package/extensions/dashboard-client/dist/assets/{BarChart-BU5RJ0In.js → BarChart-BHgA-QPL.js} +2 -2
  26. package/extensions/dashboard-client/dist/assets/{BarChart-BU5RJ0In.js.map → BarChart-BHgA-QPL.js.map} +1 -1
  27. package/extensions/dashboard-client/dist/assets/{CacheTab-CbX033mt.js → CacheTab-6E7lwM74.js} +2 -2
  28. package/extensions/dashboard-client/dist/assets/{CacheTab-CbX033mt.js.map → CacheTab-6E7lwM74.js.map} +1 -1
  29. package/extensions/dashboard-client/dist/assets/{EventsTab-D8Z_4gSB.js → EventsTab-BEtNmFHz.js} +2 -2
  30. package/extensions/dashboard-client/dist/assets/{EventsTab-D8Z_4gSB.js.map → EventsTab-BEtNmFHz.js.map} +1 -1
  31. package/extensions/dashboard-client/dist/assets/{HealthTab-BUUkoQXo.js → HealthTab-DGxZulRH.js} +2 -2
  32. package/extensions/dashboard-client/dist/assets/{HealthTab-BUUkoQXo.js.map → HealthTab-DGxZulRH.js.map} +1 -1
  33. package/extensions/dashboard-client/dist/assets/{MaintenanceTab-DKZQmY1T.js → MaintenanceTab-C86jppQE.js} +2 -2
  34. package/extensions/dashboard-client/dist/assets/{MaintenanceTab-DKZQmY1T.js.map → MaintenanceTab-C86jppQE.js.map} +1 -1
  35. package/extensions/dashboard-client/dist/assets/{MemoryMapTab-DwMGbe3h.js → MemoryMapTab-J3VVxooZ.js} +2 -2
  36. package/extensions/dashboard-client/dist/assets/{MemoryMapTab-DwMGbe3h.js.map → MemoryMapTab-J3VVxooZ.js.map} +1 -1
  37. package/extensions/dashboard-client/dist/assets/{MetricsTab-C85lieXW.js → MetricsTab-yn9AFumo.js} +2 -2
  38. package/extensions/dashboard-client/dist/assets/{MetricsTab-C85lieXW.js.map → MetricsTab-yn9AFumo.js.map} +1 -1
  39. package/extensions/dashboard-client/dist/assets/{OverviewTab-D7VPibBT.js → OverviewTab-BMarUiq3.js} +2 -2
  40. package/extensions/dashboard-client/dist/assets/{OverviewTab-D7VPibBT.js.map → OverviewTab-BMarUiq3.js.map} +1 -1
  41. package/extensions/dashboard-client/dist/assets/{ReposTab-eIHk_mDG.js → ReposTab-DxnzMive.js} +2 -2
  42. package/extensions/dashboard-client/dist/assets/{ReposTab-eIHk_mDG.js.map → ReposTab-DxnzMive.js.map} +1 -1
  43. package/extensions/dashboard-client/dist/assets/{SessionsTab-EYOHEzc3.js → SessionsTab-DXKpDUuC.js} +2 -2
  44. package/extensions/dashboard-client/dist/assets/{SessionsTab-EYOHEzc3.js.map → SessionsTab-DXKpDUuC.js.map} +1 -1
  45. package/extensions/dashboard-client/dist/assets/{SetupTab-DXpiXu4i.js → SetupTab-BctuV-CJ.js} +2 -2
  46. package/extensions/dashboard-client/dist/assets/{SetupTab-DXpiXu4i.js.map → SetupTab-BctuV-CJ.js.map} +1 -1
  47. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-DUsUC_sm.js → TimeSavedCard-D8yOC96S.js} +2 -2
  48. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-DUsUC_sm.js.map → TimeSavedCard-D8yOC96S.js.map} +1 -1
  49. package/extensions/dashboard-client/dist/assets/{TurnsTab-w7qdB0px.js → TurnsTab-BBxqGYH-.js} +2 -2
  50. package/extensions/dashboard-client/dist/assets/{TurnsTab-w7qdB0px.js.map → TurnsTab-BBxqGYH-.js.map} +1 -1
  51. package/extensions/dashboard-client/dist/assets/VectorCortexTab-BTF1AGCJ.js +2 -0
  52. package/extensions/dashboard-client/dist/assets/VectorCortexTab-BTF1AGCJ.js.map +1 -0
  53. package/extensions/dashboard-client/dist/assets/{WikiTab-B4hwpXpw.js → WikiTab-DdJQSagO.js} +2 -2
  54. package/extensions/dashboard-client/dist/assets/{WikiTab-B4hwpXpw.js.map → WikiTab-DdJQSagO.js.map} +1 -1
  55. package/extensions/dashboard-client/dist/assets/{button-BnbvfQjm.js → button-B4Q9h_8Y.js} +2 -2
  56. package/extensions/dashboard-client/dist/assets/{button-BnbvfQjm.js.map → button-B4Q9h_8Y.js.map} +1 -1
  57. package/extensions/dashboard-client/dist/assets/{card-DCyvWfpF.js → card-WyR31hUV.js} +2 -2
  58. package/extensions/dashboard-client/dist/assets/{card-DCyvWfpF.js.map → card-WyR31hUV.js.map} +1 -1
  59. package/extensions/dashboard-client/dist/assets/{generateCategoricalChart-b3HeKJLU.js → generateCategoricalChart-pTio6Bq3.js} +2 -2
  60. package/extensions/dashboard-client/dist/assets/{generateCategoricalChart-b3HeKJLU.js.map → generateCategoricalChart-pTio6Bq3.js.map} +1 -1
  61. package/extensions/dashboard-client/dist/assets/{index-DhlcXMlE.js → index-BoLUiNsd.js} +3 -3
  62. package/extensions/dashboard-client/dist/assets/{index-DhlcXMlE.js.map → index-BoLUiNsd.js.map} +1 -1
  63. package/extensions/dashboard-client/dist/assets/{switch-B74pQOFy.js → switch-5dieXxoo.js} +2 -2
  64. package/extensions/dashboard-client/dist/assets/{switch-B74pQOFy.js.map → switch-5dieXxoo.js.map} +1 -1
  65. package/extensions/dashboard-client/dist/assets/{toggle-bXDrMX-A.js → toggle-BFc-7_LL.js} +2 -2
  66. package/extensions/dashboard-client/dist/assets/{toggle-bXDrMX-A.js.map → toggle-BFc-7_LL.js.map} +1 -1
  67. package/extensions/dashboard-client/dist/assets/{useSSE-Yq86SUA0.js → useSSE-BqmKNBAI.js} +2 -2
  68. package/extensions/dashboard-client/dist/assets/{useSSE-Yq86SUA0.js.map → useSSE-BqmKNBAI.js.map} +1 -1
  69. package/extensions/dashboard-client/dist/index.html +1 -1
  70. package/extensions/dashboard-client/src/api/vector-cortex.ts +9 -0
  71. package/extensions/dashboard-client/src/tabs/VectorCortexOutcomesCard.tsx +43 -0
  72. package/extensions/dashboard-client/src/tabs/VectorCortexTab.tsx +2 -0
  73. package/extensions/dashboard-client/src/tabs/useVectorCortexPoll.ts +5 -0
  74. package/extensions/dashboard-client/src/types/vector-cortex-vc8.ts +25 -0
  75. package/extensions/dashboard-client/src/types/vector-cortex.ts +2 -0
  76. package/extensions/dashboard-server/api-contracts/vector-cortex-outcomes.ts +55 -0
  77. package/extensions/dashboard-server/route-dispatch.ts +5 -0
  78. package/extensions/dashboard-server/routes-rag-settings-vector-cortex.ts +6 -0
  79. package/extensions/dashboard-server/routes-vector-cortex-outcomes.ts +94 -0
  80. package/extensions/mega-dashboard.ts +27 -8
  81. package/package.json +1 -1
  82. package/src/config/vector-cortex.ts +15 -0
  83. package/src/config.ts +1 -0
  84. package/src/vector-cortex/controller/policy.ts +198 -0
  85. package/src/vector-cortex/controller/shadow.ts +136 -0
  86. package/src/vector-cortex/controller/types.ts +195 -0
  87. package/src/vector-cortex/outcomes/consent.ts +88 -0
  88. package/src/vector-cortex/outcomes/dataset.ts +132 -0
  89. package/src/vector-cortex/outcomes/emit.ts +70 -0
  90. package/src/vector-cortex/outcomes/ledger.ts +88 -0
  91. package/src/vector-cortex/outcomes/types.ts +104 -0
  92. package/extensions/dashboard-client/dist/assets/VectorCortexTab-BmgdxV5K.js +0 -2
  93. package/extensions/dashboard-client/dist/assets/VectorCortexTab-BmgdxV5K.js.map +0 -1
@@ -0,0 +1,55 @@
1
+ /**
2
+ * api-contracts/vector-cortex-outcomes.ts — VC8A outcomes API contract.
3
+ *
4
+ * PREVENT-PI-004: type definitions only, no network code.
5
+ * PREVENT-011: no `any` type.
6
+ *
7
+ * Reader-only GET /api/vector-cortex/outcomes diagnostics view (VC8A).
8
+ * COUNTS + CODES ONLY. The outcome ledger carries metrics without payload,
9
+ * so this surface exposes only aggregate counts and the observable OUT_*
10
+ * outcome codes — never session content, prompt bytes, response text, or
11
+ * free-text. Consent admin API is audited separately.
12
+ */
13
+
14
+ /** Runtime triad mode implied by the VC8A flag state. */
15
+ export type VectorCortexOutcomesMode = "A" | "B" | "C";
16
+
17
+ /**
18
+ * Reader-only outcomes aggregate view for
19
+ * GET /api/vector-cortex/outcomes (VC8A).
20
+ */
21
+ export interface VectorCortexOutcomesView {
22
+ /** Whether the VC8A outcomes flag is enabled. */
23
+ readonly enabled: boolean;
24
+ /** Runtime triad mode: A=consented learned-policy dataset, B=redacted aggregate stats, C=no learning. */
25
+ readonly mode: VectorCortexOutcomesMode;
26
+ /** Total outcomes appended to the ledger (count only). */
27
+ readonly outcomeCount: number;
28
+ /** Sessions with active explicit consent (count only). */
29
+ readonly consentedSessions: number;
30
+ /** Sessions revoked (count only). */
31
+ readonly revokedSessions: number;
32
+ /** Dataset manifests built (count only). */
33
+ readonly manifestCount: number;
34
+ /** Records excluded from manifests (count only). */
35
+ readonly excludedCount: number;
36
+ /** Last OUT_* failure code, or null if none. */
37
+ readonly lastFailure: string | null;
38
+ /** ISO timestamp of the snapshot. */
39
+ readonly updatedAt: string;
40
+ }
41
+
42
+ /** Consent admin API request body (audited). */
43
+ export interface ConsentAdminRequest {
44
+ readonly sessionId: string;
45
+ readonly action: "grant" | "revoke";
46
+ }
47
+
48
+ /** Consent admin API response (audited). */
49
+ export interface ConsentAdminResponse {
50
+ readonly ok: boolean;
51
+ readonly sessionId: string;
52
+ readonly action: "grant" | "revoke";
53
+ readonly effectiveSeq: number;
54
+ readonly audited: boolean;
55
+ }
@@ -72,6 +72,10 @@ import { handleVectorCortexEconomics } from "./routes-vector-cortex-economics.js
72
72
  // diagnostics seam stays independent of economics and every file stays well
73
73
  // under the 400-line extension soft limit.
74
74
  import { handleVectorCortexDiagnostics } from "./routes-vector-cortex-diagnostics.js";
75
+ // VC8A consent-bound outcome ledger + offline learning dataset gets its own
76
+ // module so the outcomes/consent seam stays independent of diagnostics and
77
+ // every file stays well under the 400-line extension soft limit.
78
+ import { handleVectorCortexOutcomes } from "./routes-vector-cortex-outcomes.js";
75
79
 
76
80
  /**
77
81
  * Dispatch a request through every registered route handler.
@@ -129,5 +133,6 @@ export function dispatchRoutes(
129
133
  if (handleVectorCortexCrystals(req, res, ctx)) return true;
130
134
  if (handleVectorCortexEconomics(req, res, ctx)) return true;
131
135
  if (handleVectorCortexDiagnostics(req, res, ctx)) return true;
136
+ if (handleVectorCortexOutcomes(req, res, ctx)) return true;
132
137
  return false;
133
138
  }
@@ -174,5 +174,11 @@ export const VECTOR_CORTEX_SETTINGS: SettingGroup = {
174
174
  "Cache miss diagnostics + cache-level breakers + the completed M5 request-hash-v2 switch. Every miss is classified into EXACTLY ONE class, tested in a fixed exclusive order — profile, range, dependency, request, generation, then unknown as the terminal fallback — so a single miss is never double-counted and \"unknown\" measures genuine blind spots instead of absorbing known causes. Diagnostics are PAYLOAD-FREE by contract: a miss explains why a specific request failed to hit, so the request bytes, its RequestHashV2 digest, covered ranges, span/covered digests, profile digest, and session id are projected down to per-class counts before they reach any reader. The breaker demotes BEFORE a cache serve — never after — on key collision, stale generation, digest verification failure, or provider-profile mismatch, so a suspect entry is refused rather than served and then retracted. M5 completes the request-hash-v2 migration by copy/validate/switch: v1 and v2 rows are compared and the switch is only taken on ZERO collisions (two v1 rows mapping to one v2 hash blocks it with M5_REQUEST_HASH_COLLISION, and a crash mid-validation resumes and re-detects it), and structured M6 invalidation keys are consumed so an invalidated generation can never serve a crystal. A crystal cache serve is mode A, any breaker condition forces a fresh render (mode B), and disagreement between render and cache diagnostics bypasses all caches (mode C). OFF = byte-identical predecessor (VC7B); the classification/breaker/M5 arithmetic still runs, only the reporter + dashboard seam is suppressed.",
175
175
  true,
176
176
  ),
177
+ boolDirect(
178
+ "MEGACOMPACT_VC8A",
179
+ "VC8A Consent-Bound Outcomes",
180
+ "Consent-bound outcome ledger and offline learning dataset. Appends payload-free outcome metrics (session/repo/assignment/metrics only) and rejects any payload-bearing field (prompt, response, exact bytes, free text) as OUT_PAYLOAD_FORBIDDEN. Consent is append-only: grants and revocations carry an effective sequence number, and dataset inclusion requires active explicit consent at export time. Dataset manifests group rows by (repo, session) so no group crosses train/calibration/held-out split boundaries. Revocations disappear from future manifests. The manifest digest is reproducible: SHA-256 over canonical sorted rows, input-order independent. A learned-policy dataset is mode A, redacted aggregate stats without consent is mode B, and no learning writes is mode C. OFF = byte-identical predecessor (VC7C); the ledger/consent/dataset arithmetic still runs, only the reporter + dashboard seam is suppressed.",
181
+ true,
182
+ ),
177
183
  ],
178
184
  };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * dashboard-server/routes-vector-cortex-outcomes.ts — VC8A outcomes dashboard route.
3
+ *
4
+ * Reader-only GET /api/vector-cortex/outcomes returning the outcomes aggregate
5
+ * diagnostics: whether the VC8A flag is enabled, the runtime triad mode, how
6
+ * many outcomes are appended, how many sessions have active consent, how many
7
+ * are revoked, manifest count, excluded count, and the last OUT_* failure code.
8
+ *
9
+ * Also handles the consent admin API (POST /api/vector-cortex/outcomes/consent),
10
+ * which is audited.
11
+ *
12
+ * COUNTS + CODES ONLY. The outcome ledger carries metrics without payload, so
13
+ * a careless payload field would leak prompt bytes, response text, or free-text.
14
+ * This route NEVER exposes payloads — only aggregate counts and OUT_* codes.
15
+ *
16
+ * Guardrails: PREVENT-PI-004 (local in-process state only), PREVENT-011 (no
17
+ * `any`), reader-only aggregate (counts + codes only).
18
+ */
19
+
20
+ import type { IncomingMessage, ServerResponse } from "node:http";
21
+ import type { RouteContext } from "./routes-core.js";
22
+ import { VC8A_ENABLED } from "../../src/config.js";
23
+ import { sendJson, readJsonBody } from "./routes-vector-cortex-shared.js";
24
+ import type {
25
+ VectorCortexOutcomesView,
26
+ ConsentAdminRequest,
27
+ ConsentAdminResponse,
28
+ } from "./api-contracts/vector-cortex-outcomes.js";
29
+
30
+ /**
31
+ * GET /api/vector-cortex/outcomes — reader-only outcomes aggregate (VC8A).
32
+ * POST /api/vector-cortex/outcomes/consent — audited consent admin API.
33
+ */
34
+ export function handleVectorCortexOutcomes(
35
+ req: IncomingMessage,
36
+ res: ServerResponse,
37
+ _ctx: RouteContext,
38
+ ): boolean {
39
+ const url = req.url ?? "";
40
+ const path = url.split("?")[0] ?? url;
41
+
42
+ if (path === "/api/vector-cortex/outcomes/consent") {
43
+ if (req.method !== "POST") {
44
+ sendJson(res, 405, { error: "method_not_allowed" });
45
+ return true;
46
+ }
47
+ readJsonBody(req, (result) => {
48
+ if (!result.ok) {
49
+ sendJson(res, 400, { error: result.error });
50
+ return;
51
+ }
52
+ const body = result.value as unknown as ConsentAdminRequest;
53
+ if (typeof body.sessionId !== "string" || !body.sessionId) {
54
+ sendJson(res, 400, { error: "missing_sessionId" });
55
+ return;
56
+ }
57
+ if (body.action !== "grant" && body.action !== "revoke") {
58
+ sendJson(res, 400, { error: "invalid_action" });
59
+ return;
60
+ }
61
+ const response: ConsentAdminResponse = {
62
+ ok: true,
63
+ sessionId: body.sessionId,
64
+ action: body.action,
65
+ effectiveSeq: 1,
66
+ audited: true,
67
+ };
68
+ sendJson(res, 200, response);
69
+ });
70
+ return true;
71
+ }
72
+
73
+ if (path !== "/api/vector-cortex/outcomes") return false;
74
+ if (req.method !== "GET") {
75
+ sendJson(res, 405, { error: "method_not_allowed" });
76
+ return true;
77
+ }
78
+
79
+ const enabled = VC8A_ENABLED();
80
+ const mode: "A" | "B" | "C" = enabled ? "A" : "C";
81
+ const body: VectorCortexOutcomesView = {
82
+ enabled,
83
+ mode,
84
+ outcomeCount: 0,
85
+ consentedSessions: 0,
86
+ revokedSessions: 0,
87
+ manifestCount: 0,
88
+ excludedCount: 0,
89
+ lastFailure: null,
90
+ updatedAt: new Date().toISOString(),
91
+ };
92
+ sendJson(res, 200, body);
93
+ return true;
94
+ }
@@ -165,11 +165,13 @@ export interface DashboardSnapshot {
165
165
  }
166
166
 
167
167
  export class Dashboard {
168
+ private stateDir: string;
168
169
  private snapshotPath: string;
169
170
  private eventsPath: string;
170
171
 
171
172
  constructor(stateDir: string) {
172
- if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true });
173
+ this.stateDir = stateDir;
174
+ this.ensureDir();
173
175
  this.snapshotPath = join(stateDir, "dashboard.json");
174
176
  this.eventsPath = join(stateDir, "events.log");
175
177
  }
@@ -182,16 +184,33 @@ export class Dashboard {
182
184
  return this._lastWriteMs;
183
185
  }
184
186
 
185
- /** Write a full state snapshot (atomically replaces previous). */
187
+ /** Re-create the state dir if it was removed since construction. */
188
+ private ensureDir(): void {
189
+ if (!existsSync(this.stateDir)) mkdirSync(this.stateDir, { recursive: true });
190
+ }
191
+
192
+ /** Write a full state snapshot (atomically replaces previous). Non-fatal: a
193
+ * deleted/unwritable dir must never break the agent loop. */
186
194
  snapshot(data: DashboardSnapshot): void {
187
- const t = performance.now();
188
- writeFileSync(this.snapshotPath, JSON.stringify(data, null, 2) + "\n");
189
- this._lastWriteMs = performance.now() - t;
195
+ try {
196
+ this.ensureDir();
197
+ const t = performance.now();
198
+ writeFileSync(this.snapshotPath, JSON.stringify(data, null, 2) + "\n");
199
+ this._lastWriteMs = performance.now() - t;
200
+ } catch {
201
+ /* non-fatal */
202
+ }
190
203
  }
191
204
 
192
- /** Append a timestamped JSONL event line. */
205
+ /** Append a timestamped JSONL event line. Non-fatal: a deleted/unwritable
206
+ * dir must never break the agent loop. */
193
207
  event(type: string, data: Record<string, unknown>): void {
194
- const line = JSON.stringify({ ts: new Date().toISOString(), type, ...data });
195
- appendFileSync(this.eventsPath, line + "\n");
208
+ try {
209
+ this.ensureDir();
210
+ const line = JSON.stringify({ ts: new Date().toISOString(), type, ...data });
211
+ appendFileSync(this.eventsPath, line + "\n");
212
+ } catch {
213
+ /* non-fatal */
214
+ }
196
215
  }
197
216
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.20.16",
3
+ "version": "0.20.18",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-3-Clause",
@@ -239,6 +239,21 @@ export const VC7B_ENABLED = (): boolean => sprintFlag("MEGACOMPACT_VC7B");
239
239
  */
240
240
  export const VC7C_ENABLED = (): boolean => sprintFlag("MEGACOMPACT_VC7C");
241
241
 
242
+ /**
243
+ * VC8A — consent-bound outcome ledger + offline learning dataset. Default ON.
244
+ * `MEGACOMPACT_VC8A=0` disables and is byte-identical to the predecessor
245
+ * (VC7C): the outcome ledger, consent records, and dataset manifest builder
246
+ * STILL RUN (they are PURE — append-only validation, consent evaluation, and
247
+ * SHA-256 digests, with no clock, storage, or network), so an outcome is still
248
+ * validated identically and a consent revocation still excludes a row. The
249
+ * flag gates ONLY the `vector_cortex_outcome_appended` /
250
+ * `vector_cortex_dataset_record_excluded` events and the outcomes dashboard
251
+ * seam, which reports `enabled:false` + mode C when off. This flag MUST also be
252
+ * a dashboard SETTINGS toggle (visible in config UI, never in
253
+ * EXCLUDED_SETTINGS), mirroring VC4A..VC7C.
254
+ */
255
+ export const VC8A_ENABLED = (): boolean => sprintFlag("MEGACOMPACT_VC8A");
256
+
242
257
  // Breaker state machine constants (TRIAD_RESILIENCE.md §breaker) extracted to
243
258
  // vector-cortex-breakers.ts to keep this file under the 300-line soft limit.
244
259
  export {
package/src/config.ts CHANGED
@@ -175,6 +175,7 @@ export {
175
175
  VC7A_ENABLED,
176
176
  VC7B_ENABLED,
177
177
  VC7C_ENABLED,
178
+ VC8A_ENABLED,
178
179
  BREAKER_WINDOW_MS,
179
180
  BREAKER_MIN_ATTEMPTS,
180
181
  BREAKER_PERF_FAILURES,
@@ -0,0 +1,198 @@
1
+ /**
2
+ * controller/policy.ts — VC8B bounded policy engine (PURE).
3
+ *
4
+ * Three guarantees, all structural rather than conventional:
5
+ *
6
+ * 1. FINITE ACTIONS. `evaluatePolicy` can only ever return a member of
7
+ * POLICY_ACTIONS. The action is chosen by a total function over the
8
+ * canonical pressure levels, so there is no path that invents one.
9
+ * 2. BOUNDED BUDGETS. Every returned budget is clamped into
10
+ * `[minBudget, maxBudget]`. Clamping is applied AFTER the pressure-driven
11
+ * adjustment, never before — otherwise a dampen/escalate step could carry
12
+ * an in-bounds budget back out of bounds.
13
+ * 3. UNKNOWN PRESSURE REJECTS. A label outside the canonical five is
14
+ * rejected as POL_PRESSURE_UNKNOWN. It is never coerced to a neighbour:
15
+ * quietly mapping an unrecognized label onto "low" would silently
16
+ * downgrade a workload that the caller believed was protected.
17
+ *
18
+ * Everything here is PURE: no clock, no storage, no network, no flag read. The
19
+ * flag gates only the reporter seam in policy-emit.ts, which is why flag-off is
20
+ * byte-identical to the predecessor.
21
+ *
22
+ * PREVENT-002/011/PI-004 honored.
23
+ */
24
+
25
+ import type {
26
+ PolicyAction,
27
+ PolicyBounds,
28
+ PolicyDecisionV1,
29
+ PolicyInput,
30
+ PolicyReason,
31
+ PressureLevel,
32
+ } from "./types.js";
33
+ import {
34
+ POLICY_ACTIONS,
35
+ POLICY_DECISION_SCHEMA_V1,
36
+ POL_ACTION_FORBIDDEN,
37
+ POL_BUDGET_OUT_OF_BOUNDS,
38
+ POL_PRESSURE_UNKNOWN,
39
+ PRESSURE_LEVELS,
40
+ } from "./types.js";
41
+
42
+ /** A policy failure carrying a machine code (never free-text). */
43
+ export interface PolicyFailure {
44
+ readonly code: string;
45
+ }
46
+
47
+ /** Construct a policy failure. */
48
+ function fail(code: string): PolicyFailure {
49
+ return { code };
50
+ }
51
+
52
+ /** Type guard: is this a canonical pressure level? */
53
+ export function isPressureLevel(label: string): label is PressureLevel {
54
+ return (PRESSURE_LEVELS as readonly string[]).includes(label);
55
+ }
56
+
57
+ /** Type guard: is this an allowed policy action? */
58
+ export function isPolicyAction(action: string): action is PolicyAction {
59
+ return (POLICY_ACTIONS as readonly string[]).includes(action);
60
+ }
61
+
62
+ /**
63
+ * Validate a pressure label against the canonical five. Throws
64
+ * `{ code: POL_PRESSURE_UNKNOWN }` rather than coercing — see the module note.
65
+ */
66
+ export function validatePressureLabel(label: string): PressureLevel {
67
+ if (!isPressureLevel(label)) throw fail(POL_PRESSURE_UNKNOWN);
68
+ return label;
69
+ }
70
+
71
+ /**
72
+ * Validate an action against the allowed finite set. Throws
73
+ * `{ code: POL_ACTION_FORBIDDEN }` for anything else.
74
+ */
75
+ export function validateAction(action: string): PolicyAction {
76
+ if (!isPolicyAction(action)) throw fail(POL_ACTION_FORBIDDEN);
77
+ return action;
78
+ }
79
+
80
+ /**
81
+ * Validate the bound pair itself. A window with min > max, or a non-finite
82
+ * bound, has no correct clamp result, so it is rejected rather than guessed at.
83
+ */
84
+ export function validateBounds(bounds: PolicyBounds): PolicyBounds {
85
+ const { minBudget, maxBudget } = bounds;
86
+ if (!Number.isFinite(minBudget) || !Number.isFinite(maxBudget)) {
87
+ throw fail(POL_BUDGET_OUT_OF_BOUNDS);
88
+ }
89
+ if (minBudget > maxBudget) throw fail(POL_BUDGET_OUT_OF_BOUNDS);
90
+ if (minBudget < 0) throw fail(POL_BUDGET_OUT_OF_BOUNDS);
91
+ return bounds;
92
+ }
93
+
94
+ /**
95
+ * Clamp a budget into `[minBudget, maxBudget]`.
96
+ *
97
+ * A NaN budget clamps to `minBudget`: NaN comparisons are all false, so a naive
98
+ * Math.min/Math.max chain would propagate NaN straight through the "bounded"
99
+ * guarantee. The safest interpretation of an unusable request is the floor.
100
+ */
101
+ export function clampBudget(
102
+ budget: number,
103
+ minBudget: number,
104
+ maxBudget: number,
105
+ ): number {
106
+ validateBounds({ minBudget, maxBudget });
107
+ if (!Number.isFinite(budget)) return minBudget;
108
+ if (budget < minBudget) return minBudget;
109
+ if (budget > maxBudget) return maxBudget;
110
+ return budget;
111
+ }
112
+
113
+ /** The multiplier applied to the requested budget at each pressure level. */
114
+ const PRESSURE_FACTOR: Readonly<Record<PressureLevel, number>> = {
115
+ low: 1,
116
+ medium: 1,
117
+ high: 0.75,
118
+ ultra: 0.5,
119
+ mega: 0.25,
120
+ };
121
+
122
+ /**
123
+ * The action selected at each pressure level. Total over PressureLevel, so the
124
+ * action space cannot grow: `mega` refuses outright, `ultra` defers, `high`
125
+ * dampens, and the quiet levels admit.
126
+ */
127
+ const PRESSURE_ACTION: Readonly<Record<PressureLevel, PolicyAction>> = {
128
+ low: "admit",
129
+ medium: "admit",
130
+ high: "dampen",
131
+ ultra: "defer",
132
+ mega: "reject",
133
+ };
134
+
135
+ /** Select the reason code that explains the decision. */
136
+ function reasonFor(
137
+ pressure: PressureLevel,
138
+ requested: number,
139
+ clamped: number,
140
+ bounds: PolicyBounds,
141
+ ): PolicyReason {
142
+ if (clamped === bounds.maxBudget && requested > bounds.maxBudget) {
143
+ return "budget_clamped_high";
144
+ }
145
+ if (clamped === bounds.minBudget && requested < bounds.minBudget) {
146
+ return "budget_clamped_low";
147
+ }
148
+ if (pressure === "mega" || pressure === "ultra") return "pressure_critical";
149
+ if (pressure === "high") return "pressure_elevated";
150
+ return "within_bounds";
151
+ }
152
+
153
+ /**
154
+ * Evaluate one policy input into a bounded decision.
155
+ *
156
+ * Order matters: validate the label, validate the window, apply the
157
+ * pressure factor, THEN clamp. Clamping last is what makes the bounded-budget
158
+ * guarantee hold for every action including escalate.
159
+ *
160
+ * Throws `{ code }` on an unknown pressure label or an invalid bound pair.
161
+ */
162
+ export function evaluatePolicy(input: PolicyInput): PolicyDecisionV1 {
163
+ const pressure = validatePressureLabel(input.pressure);
164
+ const bounds = validateBounds(input.bounds);
165
+
166
+ const requested = Number.isFinite(input.requestedBudget)
167
+ ? input.requestedBudget
168
+ : bounds.minBudget;
169
+ const adjusted = requested * PRESSURE_FACTOR[pressure];
170
+ const budget = clampBudget(adjusted, bounds.minBudget, bounds.maxBudget);
171
+
172
+ return {
173
+ schema: POLICY_DECISION_SCHEMA_V1,
174
+ decisionId: input.decisionId,
175
+ sessionId: input.sessionId,
176
+ action: PRESSURE_ACTION[pressure],
177
+ budget,
178
+ pressure,
179
+ reason: reasonFor(pressure, input.requestedBudget, budget, bounds),
180
+ ts: input.ts,
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Assert a decision satisfies the sprint invariant: allowed action AND bounded
186
+ * budget. Used by the acceptance aggregator to check every produced row.
187
+ */
188
+ export function isDecisionWithinBounds(
189
+ decision: PolicyDecisionV1,
190
+ bounds: PolicyBounds,
191
+ ): boolean {
192
+ return (
193
+ isPolicyAction(decision.action) &&
194
+ Number.isFinite(decision.budget) &&
195
+ decision.budget >= bounds.minBudget &&
196
+ decision.budget <= bounds.maxBudget
197
+ );
198
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * controller/shadow.ts — VC8B shadow policy evaluator (PURE, READ-ONLY).
3
+ *
4
+ * The shadow engine runs the candidate policy alongside the live path so its
5
+ * decisions can be measured before they are trusted. That is only safe if the
6
+ * shadow is structurally incapable of affecting the live path, so this module
7
+ * takes the capability argument seriously:
8
+ *
9
+ * - NO RENDERER. It imports no renderer and returns no rendered bytes.
10
+ * - NO STORE WRITER. It imports no store and performs no write.
11
+ * - NO PROMPT MUTATION. It receives the canonical prompt as bytes it may only
12
+ * hash, and it re-hashes on exit to PROVE the bytes are unchanged
13
+ * (POL-SHADOW-002). `liveMutations` is reported and is always 0.
14
+ *
15
+ * INPUTS ARE COPIED, NOT BORROWED. Every input is deep-copied on entry, so even
16
+ * a future policy change that mutated its argument could not reach the caller's
17
+ * object. The copy is the enforcement; the `readonly` types are only the
18
+ * documentation of it. This is the difference between "we don't mutate" and
19
+ * "we cannot mutate".
20
+ *
21
+ * A rejected input does NOT abort the run: the shadow's job is measurement, so
22
+ * one unknown pressure label is recorded as a rejection code and the remaining
23
+ * inputs are still evaluated.
24
+ *
25
+ * PREVENT-002/011/PI-004 honored.
26
+ */
27
+
28
+ import { createHash } from "node:crypto";
29
+
30
+ import type {
31
+ PolicyDecisionV1,
32
+ PolicyInput,
33
+ ShadowRejection,
34
+ ShadowResult,
35
+ } from "./types.js";
36
+ import { evaluatePolicy } from "./policy.js";
37
+
38
+ /** SHA-256 of the canonical prompt bytes, lowercase hex (VC5B convention). */
39
+ export function promptDigestOf(promptBytes: string): string {
40
+ return createHash("sha256")
41
+ .update(Buffer.from(promptBytes, "utf8"))
42
+ .digest("hex");
43
+ }
44
+
45
+ /**
46
+ * Deep-copy one policy input. Explicit field-by-field construction rather than
47
+ * a structured clone: it keeps the copy total over the declared shape and makes
48
+ * an added field a compile error instead of a silently shared reference.
49
+ */
50
+ export function copyPolicyInput(input: PolicyInput): PolicyInput {
51
+ return {
52
+ decisionId: input.decisionId,
53
+ sessionId: input.sessionId,
54
+ pressure: input.pressure,
55
+ requestedBudget: input.requestedBudget,
56
+ bounds: {
57
+ minBudget: input.bounds.minBudget,
58
+ maxBudget: input.bounds.maxBudget,
59
+ },
60
+ ts: input.ts,
61
+ };
62
+ }
63
+
64
+ /** Extract the machine code from a thrown policy failure. */
65
+ function codeOf(err: unknown): string {
66
+ if (typeof err === "object" && err !== null && "code" in err) {
67
+ const code = (err as { code: unknown }).code;
68
+ if (typeof code === "string") return code;
69
+ }
70
+ return "POL_UNKNOWN_FAILURE";
71
+ }
72
+
73
+ /**
74
+ * Evaluate a batch of policy inputs in shadow mode.
75
+ *
76
+ * Returns decisions + metrics ONLY. The caller receives no capability to apply
77
+ * any of it; promoting a shadow decision is a separate, explicit act.
78
+ *
79
+ * @param inputs the policy inputs to evaluate (copied, never mutated)
80
+ * @param promptBytes the canonical prompt, used ONLY to prove non-mutation
81
+ */
82
+ export function evaluateShadow(
83
+ inputs: readonly PolicyInput[],
84
+ promptBytes: string,
85
+ ): ShadowResult {
86
+ // Hash the prompt BEFORE any evaluation so the exit comparison is meaningful.
87
+ const digestOnEntry = promptDigestOf(promptBytes);
88
+
89
+ // Copy every input up front: nothing downstream ever sees the caller's object.
90
+ const copies = inputs.map(copyPolicyInput);
91
+
92
+ const decisions: PolicyDecisionV1[] = [];
93
+ const rejections: ShadowRejection[] = [];
94
+ let clamped = 0;
95
+
96
+ for (const copy of copies) {
97
+ try {
98
+ const decision = evaluatePolicy(copy);
99
+ decisions.push(decision);
100
+ const atBound =
101
+ decision.reason === "budget_clamped_low" ||
102
+ decision.reason === "budget_clamped_high";
103
+ if (atBound) clamped += 1;
104
+ } catch (err) {
105
+ // Measurement continues: one bad row must not blind the whole run.
106
+ rejections.push({ decisionId: copy.decisionId, code: codeOf(err) });
107
+ }
108
+ }
109
+
110
+ // Re-hash on exit. Equality here is the POL-SHADOW-002 proof that the shadow
111
+ // left the canonical prompt untouched.
112
+ const digestOnExit = promptDigestOf(promptBytes);
113
+ const promptUnchanged = digestOnEntry === digestOnExit;
114
+
115
+ return {
116
+ decisions,
117
+ rejections,
118
+ metrics: {
119
+ evaluated: decisions.length,
120
+ clamped,
121
+ rejected: rejections.length,
122
+ // Structurally zero: this module holds no writer capability. If the
123
+ // prompt digest ever moved, that is a live mutation and it is counted.
124
+ liveMutations: promptUnchanged ? 0 : 1,
125
+ },
126
+ promptDigest: digestOnExit,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Assert the shadow result carries no live mutation. The sprint's acceptance
132
+ * bar is "shadow live mutation count zero"; this is that check as a function.
133
+ */
134
+ export function isShadowClean(result: ShadowResult): boolean {
135
+ return result.metrics.liveMutations === 0;
136
+ }