@principles/host-runtime 0.1.0 → 0.2.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.
- package/dist/active-principle-prompt.js +5 -1
- package/dist/governance-observation-store.d.ts +196 -0
- package/dist/governance-observation-store.js +696 -0
- package/dist/governance-signal-admission.d.ts +228 -0
- package/dist/governance-signal-admission.js +853 -0
- package/dist/host-liveness-contract.d.ts +3 -0
- package/dist/host-liveness-contract.js +18 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +17 -0
- package/dist/internalization-consumer-cycle.d.ts +43 -0
- package/dist/internalization-consumer-cycle.js +468 -0
- package/dist/internalization-consumer-governance.d.ts +39 -0
- package/dist/internalization-consumer-governance.js +152 -0
- package/dist/legacy-rule-contract-symbols.d.ts +20 -0
- package/dist/legacy-rule-contract-symbols.js +124 -0
- package/dist/product-telemetry/consent-store.d.ts +107 -0
- package/dist/product-telemetry/consent-store.js +329 -0
- package/dist/product-telemetry/eligibility.d.ts +53 -0
- package/dist/product-telemetry/eligibility.js +69 -0
- package/dist/product-telemetry/exporter.d.ts +46 -0
- package/dist/product-telemetry/exporter.js +71 -0
- package/dist/product-telemetry/milestone-readers.d.ts +61 -0
- package/dist/product-telemetry/milestone-readers.js +234 -0
- package/dist/product-telemetry/service.d.ts +142 -0
- package/dist/product-telemetry/service.js +532 -0
- package/dist/product-telemetry/workspace-scope.d.ts +38 -0
- package/dist/product-telemetry/workspace-scope.js +64 -0
- package/dist/production-pain-evidence.d.ts +60 -0
- package/dist/production-pain-evidence.js +82 -12
- package/dist/production-rulehost-gate.d.ts +0 -1
- package/dist/production-rulehost-gate.js +47 -6
- package/dist/rule-implementation-runtime.js +0 -2
- package/dist/workspace-telemetry-emitter.d.ts +15 -0
- package/dist/workspace-telemetry-emitter.js +74 -0
- package/package.json +7 -2
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product telemetry control-state store — Anonymous Product Telemetry v1
|
|
3
|
+
* (PRI-597, SPEC §44; review remediation: workspace-scoped export state).
|
|
4
|
+
*
|
|
5
|
+
* Persists the telemetry control state to `~/.pd/product-telemetry.json`.
|
|
6
|
+
* This file is Telemetry Control State — it never enters
|
|
7
|
+
* Principle/Pain/receipt/governance stores, and the secret never leaves the
|
|
8
|
+
* machine.
|
|
9
|
+
*
|
|
10
|
+
* Scope model (schema v2, review remediation P1-1):
|
|
11
|
+
* - MACHINE scope: consent, consentVersion, telemetrySecret.
|
|
12
|
+
* - WORKSPACE scope: `workspaceExports[scopeId]` — per-workspace dedup,
|
|
13
|
+
* retry, and attempt bookkeeping keyed by the opaque local scope ID
|
|
14
|
+
* (HMAC(secret, canonical workspace path); never uploaded). One
|
|
15
|
+
* workspace succeeding must never suppress another workspace's export.
|
|
16
|
+
*
|
|
17
|
+
* Migration from schema v1: consent, consentVersion, and telemetrySecret are
|
|
18
|
+
* preserved; legacy machine-global export bookkeeping (lastSucceededAt,
|
|
19
|
+
* retry state, attempt counters) is DISCARDED — it cannot be attributed to a
|
|
20
|
+
* workspace, and it is operational state, not a governance fact. Dropping it
|
|
21
|
+
* can cause at most one extra same-day snapshot per installation.
|
|
22
|
+
*/
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { generateTelemetrySecretHex, isValidBucketDate, isValidTelemetrySecretHex, PRODUCT_TELEMETRY_CONSENT_VERSION, } from '@principles/core/runtime-v2';
|
|
26
|
+
export const PRODUCT_TELEMETRY_STATE_FILENAME = 'product-telemetry.json';
|
|
27
|
+
export const PRODUCT_TELEMETRY_CONTROL_SCHEMA_VERSION = '2';
|
|
28
|
+
/** v1 files are still read (then migrated in memory and rewritten as v2). */
|
|
29
|
+
export const PRODUCT_TELEMETRY_LEGACY_CONTROL_SCHEMA_VERSION = '1';
|
|
30
|
+
/** Hard bound on tracked workspaces so the file cannot grow unboundedly. */
|
|
31
|
+
export const MAX_WORKSPACE_EXPORT_ENTRIES = 200;
|
|
32
|
+
/**
|
|
33
|
+
* Entries untouched for this long are pruned on the next write. This is local
|
|
34
|
+
* telemetry operational state only — no workspace history is retained.
|
|
35
|
+
*/
|
|
36
|
+
export const WORKSPACE_EXPORT_STATE_MAX_AGE_DAYS = 30;
|
|
37
|
+
export function defaultProductTelemetryControlState() {
|
|
38
|
+
return { consent: 'unset', consentVersion: PRODUCT_TELEMETRY_CONSENT_VERSION, schemaVersion: PRODUCT_TELEMETRY_CONTROL_SCHEMA_VERSION };
|
|
39
|
+
}
|
|
40
|
+
export function getProductTelemetryStatePath(homeDir) {
|
|
41
|
+
return path.join(path.resolve(homeDir), '.pd', PRODUCT_TELEMETRY_STATE_FILENAME);
|
|
42
|
+
}
|
|
43
|
+
function isConsent(value) {
|
|
44
|
+
return value === 'unset' || value === 'granted' || value === 'denied';
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Optional timestamp fields must be parseable dates, not just short strings —
|
|
48
|
+
* a "garbage" lastSucceededAt/nextRetryAt would otherwise slip validation and
|
|
49
|
+
* turn into NaN inside Date.parse, silently skipping same-day dedup or retry
|
|
50
|
+
* backoff (fail-loud contract, review round 2).
|
|
51
|
+
*/
|
|
52
|
+
function isOptionalIsoString(value) {
|
|
53
|
+
if (value === undefined)
|
|
54
|
+
return true;
|
|
55
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 40)
|
|
56
|
+
return false;
|
|
57
|
+
return !Number.isNaN(Date.parse(value));
|
|
58
|
+
}
|
|
59
|
+
function isOptionalAttemptBucketDate(value) {
|
|
60
|
+
if (value === undefined)
|
|
61
|
+
return true;
|
|
62
|
+
return isValidBucketDate(value);
|
|
63
|
+
}
|
|
64
|
+
function isOptionalAttemptCount(value) {
|
|
65
|
+
return value === undefined || (typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 99);
|
|
66
|
+
}
|
|
67
|
+
function isOptionalFailureCode(value) {
|
|
68
|
+
return value === undefined || (typeof value === 'string' && value.length > 0 && value.length <= 40);
|
|
69
|
+
}
|
|
70
|
+
function isScopeKey(value) {
|
|
71
|
+
return typeof value === 'string' && /^[0-9a-f]{4,32}$/.test(value);
|
|
72
|
+
}
|
|
73
|
+
/** Validate one `workspaceExports` entry; collect errors under `prefix`. */
|
|
74
|
+
function workspaceExportErrors(entry, prefix) {
|
|
75
|
+
const errors = [];
|
|
76
|
+
for (const key of Object.keys(entry)) {
|
|
77
|
+
if (!['lastAttemptedAt', 'lastSucceededAt', 'lastFailureCode', 'nextRetryAt', 'dailyAttemptCount', 'attemptBucketDate'].includes(key)) {
|
|
78
|
+
errors.push(`${prefix}: unknown field '${key}'`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!isOptionalIsoString(entry.lastAttemptedAt) || !isOptionalIsoString(entry.lastSucceededAt) || !isOptionalIsoString(entry.nextRetryAt)) {
|
|
82
|
+
errors.push(`${prefix}: timestamps must be parseable ISO-8601 strings (≤40 chars) when present`);
|
|
83
|
+
}
|
|
84
|
+
if (!isOptionalAttemptCount(entry.dailyAttemptCount))
|
|
85
|
+
errors.push(`${prefix}: dailyAttemptCount must be an integer 0–99 when present`);
|
|
86
|
+
if (!isOptionalAttemptBucketDate(entry.attemptBucketDate))
|
|
87
|
+
errors.push(`${prefix}: attemptBucketDate must be a valid YYYY-MM-DD UTC date when present`);
|
|
88
|
+
if (!isOptionalFailureCode(entry.lastFailureCode))
|
|
89
|
+
errors.push(`${prefix}: lastFailureCode must be a short non-empty string when present`);
|
|
90
|
+
return errors;
|
|
91
|
+
}
|
|
92
|
+
function isPlainRecord(value) {
|
|
93
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Read and validate the control state. A missing file is the normal
|
|
97
|
+
* never-configured case (defaults, existed=false). A malformed file is a
|
|
98
|
+
* loud failure — silently treating it as "unset" could re-prompt or re-export
|
|
99
|
+
* against the user's recorded decision (rc-3/rc-9).
|
|
100
|
+
*
|
|
101
|
+
* v1 files are migrated in memory (export bookkeeping dropped, consent
|
|
102
|
+
* identity preserved); the migration is persisted on the next write.
|
|
103
|
+
*/
|
|
104
|
+
export function readProductTelemetryControlState(homeDir) {
|
|
105
|
+
const filePath = getProductTelemetryStatePath(homeDir);
|
|
106
|
+
let raw;
|
|
107
|
+
try {
|
|
108
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
// Guarded code access (rc-1/rc-2: runtime check before use; rc-5:
|
|
112
|
+
// Object.hasOwn on untrusted objects; no NodeJS namespace in this config).
|
|
113
|
+
const codeValue = typeof error === 'object' && error !== null && Object.hasOwn(error, 'code')
|
|
114
|
+
? error.code
|
|
115
|
+
: undefined;
|
|
116
|
+
const code = typeof codeValue === 'string' ? codeValue : undefined;
|
|
117
|
+
if (code === 'ENOENT') {
|
|
118
|
+
return { ok: true, state: defaultProductTelemetryControlState(), existed: false };
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
reason: `product_telemetry_state_unreadable: ${code ?? String(error)}`,
|
|
123
|
+
nextAction: `Check permissions on ${filePath}`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
let parsed;
|
|
127
|
+
try {
|
|
128
|
+
parsed = JSON.parse(raw);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
reason: 'product_telemetry_state_malformed_json',
|
|
134
|
+
nextAction: `Fix or delete ${filePath} (delete = consent returns to unset)`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
reason: 'product_telemetry_state_malformed_shape',
|
|
141
|
+
nextAction: `Fix or delete ${filePath} (delete = consent returns to unset)`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const obj = parsed;
|
|
145
|
+
const { schemaVersion } = obj;
|
|
146
|
+
const isV1 = schemaVersion === PRODUCT_TELEMETRY_LEGACY_CONTROL_SCHEMA_VERSION;
|
|
147
|
+
const isV2 = schemaVersion === PRODUCT_TELEMETRY_CONTROL_SCHEMA_VERSION;
|
|
148
|
+
if (!isV1 && !isV2) {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
reason: `product_telemetry_state_malformed: schemaVersion must be '${PRODUCT_TELEMETRY_CONTROL_SCHEMA_VERSION}' or legacy '${PRODUCT_TELEMETRY_LEGACY_CONTROL_SCHEMA_VERSION}'`,
|
|
152
|
+
nextAction: `Fix or delete ${filePath} (delete = consent returns to unset)`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
// v1 machine-global export fields exist only in v1 files; v2 files carry
|
|
156
|
+
// workspaceExports instead. Unknown keys are rejected per shape.
|
|
157
|
+
const allowedKeys = isV1
|
|
158
|
+
? ['consent', 'consentVersion', 'telemetrySecret', 'lastAttemptedAt', 'lastSucceededAt', 'lastFailureCode', 'nextRetryAt', 'dailyAttemptCount', 'attemptBucketDate', 'schemaVersion']
|
|
159
|
+
: ['consent', 'consentVersion', 'telemetrySecret', 'workspaceExports', 'schemaVersion'];
|
|
160
|
+
const errors = [];
|
|
161
|
+
for (const key of Object.keys(obj)) {
|
|
162
|
+
if (!allowedKeys.includes(key)) {
|
|
163
|
+
errors.push(`unknown field '${key}'`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (!isConsent(obj.consent))
|
|
167
|
+
errors.push('consent must be unset|granted|denied');
|
|
168
|
+
if (typeof obj.consentVersion !== 'string' || obj.consentVersion.length === 0 || obj.consentVersion.length > 8)
|
|
169
|
+
errors.push('consentVersion must be a short non-empty string');
|
|
170
|
+
if (!isValidTelemetrySecretHex(obj.telemetrySecret) && obj.telemetrySecret !== undefined)
|
|
171
|
+
errors.push('telemetrySecret must be 64 hex chars when present');
|
|
172
|
+
if (isV1) {
|
|
173
|
+
// v1 legacy export fields are migrated by DISCARDING them — validating
|
|
174
|
+
// values that are about to be dropped would only turn a smooth migration
|
|
175
|
+
// into a hard failure (the identity fields carry the user's decision).
|
|
176
|
+
// Only the field-NAME allowlist above still applies to v1 files.
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
if (obj.workspaceExports !== undefined) {
|
|
180
|
+
if (typeof obj.workspaceExports !== 'object' || obj.workspaceExports === null || Array.isArray(obj.workspaceExports)) {
|
|
181
|
+
errors.push('workspaceExports must be an object');
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
const entries = obj.workspaceExports;
|
|
185
|
+
const scopeKeys = Object.keys(entries);
|
|
186
|
+
if (scopeKeys.length > MAX_WORKSPACE_EXPORT_ENTRIES) {
|
|
187
|
+
errors.push(`workspaceExports must track at most ${MAX_WORKSPACE_EXPORT_ENTRIES} workspaces`);
|
|
188
|
+
}
|
|
189
|
+
for (const scopeKey of scopeKeys) {
|
|
190
|
+
if (!isScopeKey(scopeKey)) {
|
|
191
|
+
errors.push(`workspaceExports key '${scopeKey}' must be 4–32 hex chars`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const entry = entries[scopeKey];
|
|
195
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
196
|
+
errors.push(`workspaceExports['${scopeKey}'] must be an object`);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
errors.push(...workspaceExportErrors(entry, `workspaceExports['${scopeKey}']`));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (errors.length > 0) {
|
|
205
|
+
return {
|
|
206
|
+
ok: false,
|
|
207
|
+
reason: `product_telemetry_state_malformed: ${errors.join('; ')}`,
|
|
208
|
+
nextAction: `Fix or delete ${filePath} (delete = consent returns to unset)`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
// Post-validation reconstruction from guard-narrowed fields — no `as` on
|
|
212
|
+
// the untrusted parsed object (rc-2). isConsent is a type guard; the
|
|
213
|
+
// optional-field spreads carry only guard-passing values. v1 export
|
|
214
|
+
// bookkeeping is deliberately NOT carried over (see module doc).
|
|
215
|
+
const state = {
|
|
216
|
+
consent: isConsent(obj.consent) ? obj.consent : 'unset',
|
|
217
|
+
consentVersion: typeof obj.consentVersion === 'string' ? obj.consentVersion : PRODUCT_TELEMETRY_CONSENT_VERSION,
|
|
218
|
+
...(isValidTelemetrySecretHex(obj.telemetrySecret) ? { telemetrySecret: obj.telemetrySecret } : {}),
|
|
219
|
+
...(isV2 && isPlainRecord(obj.workspaceExports)
|
|
220
|
+
? {
|
|
221
|
+
workspaceExports: Object.fromEntries(Object.entries(obj.workspaceExports)
|
|
222
|
+
.filter(([scopeKey, entry]) => isScopeKey(scopeKey) && isPlainRecord(entry))
|
|
223
|
+
.map(([scopeKey, entry]) => {
|
|
224
|
+
const record = entry;
|
|
225
|
+
return [
|
|
226
|
+
scopeKey,
|
|
227
|
+
{
|
|
228
|
+
...(isOptionalIsoString(record.lastAttemptedAt) && record.lastAttemptedAt !== undefined ? { lastAttemptedAt: record.lastAttemptedAt } : {}),
|
|
229
|
+
...(isOptionalIsoString(record.lastSucceededAt) && record.lastSucceededAt !== undefined ? { lastSucceededAt: record.lastSucceededAt } : {}),
|
|
230
|
+
...(isOptionalIsoString(record.nextRetryAt) && record.nextRetryAt !== undefined ? { nextRetryAt: record.nextRetryAt } : {}),
|
|
231
|
+
...(isOptionalFailureCode(record.lastFailureCode) && record.lastFailureCode !== undefined ? { lastFailureCode: record.lastFailureCode } : {}),
|
|
232
|
+
...(isOptionalAttemptCount(record.dailyAttemptCount) && record.dailyAttemptCount !== undefined ? { dailyAttemptCount: record.dailyAttemptCount } : {}),
|
|
233
|
+
...(isOptionalAttemptBucketDate(record.attemptBucketDate) && record.attemptBucketDate !== undefined ? { attemptBucketDate: record.attemptBucketDate } : {}),
|
|
234
|
+
},
|
|
235
|
+
];
|
|
236
|
+
})),
|
|
237
|
+
}
|
|
238
|
+
: {}),
|
|
239
|
+
schemaVersion: PRODUCT_TELEMETRY_CONTROL_SCHEMA_VERSION,
|
|
240
|
+
};
|
|
241
|
+
if (state.workspaceExports !== undefined && Object.keys(state.workspaceExports).length === 0) {
|
|
242
|
+
delete state.workspaceExports;
|
|
243
|
+
}
|
|
244
|
+
return { ok: true, state, existed: true };
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Drop `workspaceExports` entries whose most recent activity (attempt or
|
|
248
|
+
* success) is older than WORKSPACE_EXPORT_STATE_MAX_AGE_DAYS. Bounded local
|
|
249
|
+
* operational state only — this is not a workspace history database.
|
|
250
|
+
*/
|
|
251
|
+
export function pruneWorkspaceExports(state, nowMs) {
|
|
252
|
+
const exports = state.workspaceExports;
|
|
253
|
+
if (exports === undefined)
|
|
254
|
+
return state;
|
|
255
|
+
const cutoff = nowMs - WORKSPACE_EXPORT_STATE_MAX_AGE_DAYS * 24 * 60 * 60 * 1000;
|
|
256
|
+
const kept = {};
|
|
257
|
+
for (const [scopeKey, entry] of Object.entries(exports)) {
|
|
258
|
+
const stamps = [entry.lastAttemptedAt, entry.lastSucceededAt].filter((s) => s !== undefined).map((s) => Date.parse(s));
|
|
259
|
+
const latest = stamps.length > 0 ? Math.max(...stamps) : Number.NEGATIVE_INFINITY;
|
|
260
|
+
if (Number.isNaN(latest) || latest < cutoff)
|
|
261
|
+
continue;
|
|
262
|
+
kept[scopeKey] = entry;
|
|
263
|
+
}
|
|
264
|
+
const next = { ...state };
|
|
265
|
+
if (Object.keys(kept).length > 0)
|
|
266
|
+
next.workspaceExports = kept;
|
|
267
|
+
else
|
|
268
|
+
delete next.workspaceExports;
|
|
269
|
+
return next;
|
|
270
|
+
}
|
|
271
|
+
/** Atomic write (temp file + rename) so a crash never truncates the state. */
|
|
272
|
+
export function writeProductTelemetryControlState(homeDir, state) {
|
|
273
|
+
const filePath = getProductTelemetryStatePath(homeDir);
|
|
274
|
+
const dir = path.dirname(filePath);
|
|
275
|
+
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
276
|
+
try {
|
|
277
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
278
|
+
fs.writeFileSync(tmpPath, `${JSON.stringify(state, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
279
|
+
fs.renameSync(tmpPath, filePath);
|
|
280
|
+
return { ok: true };
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
284
|
+
try {
|
|
285
|
+
fs.rmSync(tmpPath, { force: true });
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
// best-effort cleanup; the write failure below is the loud signal
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
ok: false,
|
|
292
|
+
reason: `product_telemetry_state_write_failed: ${message}`,
|
|
293
|
+
nextAction: `Check permissions on ${dir}`,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/** State after `pd telemetry enable`: explicit granted consent + secret. */
|
|
298
|
+
export function grantedControlState(previous) {
|
|
299
|
+
return {
|
|
300
|
+
consent: 'granted',
|
|
301
|
+
consentVersion: PRODUCT_TELEMETRY_CONSENT_VERSION,
|
|
302
|
+
telemetrySecret: isValidTelemetrySecretHex(previous.telemetrySecret) ? previous.telemetrySecret : generateTelemetrySecretHex(),
|
|
303
|
+
...(previous.workspaceExports !== undefined ? { workspaceExports: previous.workspaceExports } : {}),
|
|
304
|
+
schemaVersion: PRODUCT_TELEMETRY_CONTROL_SCHEMA_VERSION,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* State after `pd telemetry disable`: consent denied and all export identity
|
|
309
|
+
* removed (SPEC §19). The explicit `denied` choice is preserved so PD never
|
|
310
|
+
* re-prompts.
|
|
311
|
+
*/
|
|
312
|
+
export function deniedControlState() {
|
|
313
|
+
return { consent: 'denied', consentVersion: PRODUCT_TELEMETRY_CONSENT_VERSION, schemaVersion: PRODUCT_TELEMETRY_CONTROL_SCHEMA_VERSION };
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* State after `pd telemetry reset` (SPEC §18): secret and export status are
|
|
317
|
+
* deleted — no future daily ID relates to previous ones. The consent choice
|
|
318
|
+
* is preserved; a fresh secret is generated only while telemetry remains
|
|
319
|
+
* enabled. Workspace bookkeeping keyed under the OLD secret's scope IDs is
|
|
320
|
+
* dropped with it (the IDs are meaningless under a new secret).
|
|
321
|
+
*/
|
|
322
|
+
export function resetControlState(previous) {
|
|
323
|
+
return {
|
|
324
|
+
consent: previous.consent,
|
|
325
|
+
consentVersion: PRODUCT_TELEMETRY_CONSENT_VERSION,
|
|
326
|
+
...(previous.consent === 'granted' ? { telemetrySecret: generateTelemetrySecretHex() } : {}),
|
|
327
|
+
schemaVersion: PRODUCT_TELEMETRY_CONTROL_SCHEMA_VERSION,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Production eligibility — Anonymous Product Telemetry v1
|
|
3
|
+
* (PRI-597, SPEC §35-§37).
|
|
4
|
+
*
|
|
5
|
+
* Environment suppression is evaluated independently of consent and the
|
|
6
|
+
* feature flag (both are AND-ed by the service). Every suppression reason is
|
|
7
|
+
* named in the result so `pd telemetry status` can show exactly why export is
|
|
8
|
+
* off (rc-9 — no silent suppression).
|
|
9
|
+
*
|
|
10
|
+
* Dev/CI/test detection follows the repository's explicit-marker philosophy
|
|
11
|
+
* (PD_E2E_MODE precedent): env markers and build-layout facts, never
|
|
12
|
+
* user-path heuristics.
|
|
13
|
+
*/
|
|
14
|
+
export type TelemetrySuppressionReason = 'env_kill_switch' | 'ci_environment' | 'vitest_environment' | 'e2e_mode' | 'workspace_environment' | 'install_layout_missing' | 'repo_checkout';
|
|
15
|
+
export interface TelemetryEnvironmentInput {
|
|
16
|
+
env: {
|
|
17
|
+
/** PD_TELEMETRY_DISABLED raw value. */
|
|
18
|
+
killSwitch?: string;
|
|
19
|
+
/** process.env.CI raw value. */
|
|
20
|
+
ci?: string;
|
|
21
|
+
/** process.env.VITEST raw value (set by vitest workers). */
|
|
22
|
+
vitest?: string;
|
|
23
|
+
/** PD_E2E_MODE raw value. */
|
|
24
|
+
e2eMode?: string;
|
|
25
|
+
};
|
|
26
|
+
/** workspace.environment from .pd/config.yaml (PRI-587); 'unknown' when absent. */
|
|
27
|
+
workspaceEnvironment: 'production' | 'development' | 'demo' | 'test' | 'unknown';
|
|
28
|
+
/** Install layout mode; 'missing' = nothing installed. */
|
|
29
|
+
installMode: 'canonical' | 'legacy' | 'missing';
|
|
30
|
+
/** Directory of the executing telemetry module (repo-checkout detection). */
|
|
31
|
+
moduleDir: string;
|
|
32
|
+
}
|
|
33
|
+
export interface TelemetryEnvironmentResult {
|
|
34
|
+
suppressed: boolean;
|
|
35
|
+
reasons: TelemetrySuppressionReason[];
|
|
36
|
+
}
|
|
37
|
+
/** Boolean-style env flags follow the PD_SKIP_* convention: '1' or 'true'. */
|
|
38
|
+
export declare function isEnvFlagActive(value: string | undefined): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* True when the executing module lives inside a PD monorepo checkout.
|
|
41
|
+
*
|
|
42
|
+
* Build-layout fact, not a path heuristic: an installed runtime (canonical
|
|
43
|
+
* `~/.pd/runtime/host-runtime/...` or legacy extension dir) never has a
|
|
44
|
+
* sibling `packages/principles-core` + `packages/host-runtime` tree, while
|
|
45
|
+
* a checkout always does. Prevents maintainer/AI development activity from
|
|
46
|
+
* polluting production telemetry.
|
|
47
|
+
*/
|
|
48
|
+
export declare function isRepoCheckoutModuleDir(moduleDir: string): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Evaluate environment suppression. Kill switch outranks everything; the
|
|
51
|
+
* result lists every active reason.
|
|
52
|
+
*/
|
|
53
|
+
export declare function computeTelemetryEnvironment(input: TelemetryEnvironmentInput): TelemetryEnvironmentResult;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Production eligibility — Anonymous Product Telemetry v1
|
|
3
|
+
* (PRI-597, SPEC §35-§37).
|
|
4
|
+
*
|
|
5
|
+
* Environment suppression is evaluated independently of consent and the
|
|
6
|
+
* feature flag (both are AND-ed by the service). Every suppression reason is
|
|
7
|
+
* named in the result so `pd telemetry status` can show exactly why export is
|
|
8
|
+
* off (rc-9 — no silent suppression).
|
|
9
|
+
*
|
|
10
|
+
* Dev/CI/test detection follows the repository's explicit-marker philosophy
|
|
11
|
+
* (PD_E2E_MODE precedent): env markers and build-layout facts, never
|
|
12
|
+
* user-path heuristics.
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
/** Boolean-style env flags follow the PD_SKIP_* convention: '1' or 'true'. */
|
|
17
|
+
export function isEnvFlagActive(value) {
|
|
18
|
+
return value === '1' || value === 'true';
|
|
19
|
+
}
|
|
20
|
+
function isTruthyEnv(value) {
|
|
21
|
+
return value !== undefined && value !== '' && value !== 'false' && value !== '0';
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* True when the executing module lives inside a PD monorepo checkout.
|
|
25
|
+
*
|
|
26
|
+
* Build-layout fact, not a path heuristic: an installed runtime (canonical
|
|
27
|
+
* `~/.pd/runtime/host-runtime/...` or legacy extension dir) never has a
|
|
28
|
+
* sibling `packages/principles-core` + `packages/host-runtime` tree, while
|
|
29
|
+
* a checkout always does. Prevents maintainer/AI development activity from
|
|
30
|
+
* polluting production telemetry.
|
|
31
|
+
*/
|
|
32
|
+
export function isRepoCheckoutModuleDir(moduleDir) {
|
|
33
|
+
let current = path.resolve(moduleDir);
|
|
34
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
35
|
+
const hasCore = fs.existsSync(path.join(current, 'packages', 'principles-core'));
|
|
36
|
+
const hasHostRuntime = fs.existsSync(path.join(current, 'packages', 'host-runtime'));
|
|
37
|
+
if (hasCore && hasHostRuntime) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
const parent = path.dirname(current);
|
|
41
|
+
if (parent === current)
|
|
42
|
+
break;
|
|
43
|
+
current = parent;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Evaluate environment suppression. Kill switch outranks everything; the
|
|
49
|
+
* result lists every active reason.
|
|
50
|
+
*/
|
|
51
|
+
export function computeTelemetryEnvironment(input) {
|
|
52
|
+
const reasons = [];
|
|
53
|
+
if (isEnvFlagActive(input.env.killSwitch))
|
|
54
|
+
reasons.push('env_kill_switch');
|
|
55
|
+
if (isTruthyEnv(input.env.ci))
|
|
56
|
+
reasons.push('ci_environment');
|
|
57
|
+
if (isTruthyEnv(input.env.vitest))
|
|
58
|
+
reasons.push('vitest_environment');
|
|
59
|
+
if (input.env.e2eMode === '1')
|
|
60
|
+
reasons.push('e2e_mode');
|
|
61
|
+
if (input.workspaceEnvironment === 'test' || input.workspaceEnvironment === 'demo' || input.workspaceEnvironment === 'development') {
|
|
62
|
+
reasons.push('workspace_environment');
|
|
63
|
+
}
|
|
64
|
+
if (input.installMode === 'missing')
|
|
65
|
+
reasons.push('install_layout_missing');
|
|
66
|
+
if (isRepoCheckoutModuleDir(input.moduleDir))
|
|
67
|
+
reasons.push('repo_checkout');
|
|
68
|
+
return { suppressed: reasons.length > 0, reasons };
|
|
69
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded asynchronous telemetry export client — Anonymous Product Telemetry
|
|
3
|
+
* v1 (PRI-599, SPEC §29-§34).
|
|
4
|
+
*
|
|
5
|
+
* One HTTPS POST per invocation, bounded by AbortController, never throwing.
|
|
6
|
+
* Failure mapping is coarse (SPEC §34): codes only, never messages, stacks,
|
|
7
|
+
* or response bodies. The injectable fetchFn keeps unit tests offline.
|
|
8
|
+
*/
|
|
9
|
+
import type { ProductTelemetrySnapshotV1 } from '@principles/core/runtime-v2';
|
|
10
|
+
export declare const DEFAULT_PRODUCT_TELEMETRY_ENDPOINT = "https://principles-website.pages.dev/api/product-telemetry/snapshot";
|
|
11
|
+
export declare const PRODUCT_TELEMETRY_TIMEOUT_MS = 8000;
|
|
12
|
+
export declare const PRODUCT_TELEMETRY_MAX_BODY_BYTES = 4096;
|
|
13
|
+
export type TelemetryFailureCode = 'timeout' | 'network_error' | 'http_400' | 'http_429' | 'http_5xx' | 'http_unexpected_status' | 'invalid_response';
|
|
14
|
+
export type TelemetryExportResult = {
|
|
15
|
+
ok: true;
|
|
16
|
+
status: number;
|
|
17
|
+
} | {
|
|
18
|
+
ok: false;
|
|
19
|
+
code: TelemetryFailureCode;
|
|
20
|
+
retryable: boolean;
|
|
21
|
+
};
|
|
22
|
+
export type TelemetryFetchFn = (url: string, init: {
|
|
23
|
+
method: 'POST';
|
|
24
|
+
headers: Record<string, string>;
|
|
25
|
+
body: string;
|
|
26
|
+
signal: AbortSignal;
|
|
27
|
+
}) => Promise<{
|
|
28
|
+
status: number;
|
|
29
|
+
}>;
|
|
30
|
+
export interface ExportSnapshotArgs {
|
|
31
|
+
snapshot: ProductTelemetrySnapshotV1;
|
|
32
|
+
endpoint?: string;
|
|
33
|
+
timeoutMs?: number;
|
|
34
|
+
fetchFn?: TelemetryFetchFn;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Export one snapshot. Never throws; every failure maps to a coarse code
|
|
38
|
+
* with a retryable hint the service uses for backoff.
|
|
39
|
+
*/
|
|
40
|
+
export declare function exportSnapshot(args: ExportSnapshotArgs): Promise<TelemetryExportResult>;
|
|
41
|
+
/**
|
|
42
|
+
* First failure retries after 1h; any further failure within 24h backs off 6h.
|
|
43
|
+
* Combined with the service's dailyAttemptCount hard cap this bounds a bad day
|
|
44
|
+
* to at most 5 attempts (0h/1h/7h/13h/19h), independent of clock skew.
|
|
45
|
+
*/
|
|
46
|
+
export declare function nextRetryDelayMs(previousFailureWithin24h: boolean): number;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded asynchronous telemetry export client — Anonymous Product Telemetry
|
|
3
|
+
* v1 (PRI-599, SPEC §29-§34).
|
|
4
|
+
*
|
|
5
|
+
* One HTTPS POST per invocation, bounded by AbortController, never throwing.
|
|
6
|
+
* Failure mapping is coarse (SPEC §34): codes only, never messages, stacks,
|
|
7
|
+
* or response bodies. The injectable fetchFn keeps unit tests offline.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_PRODUCT_TELEMETRY_ENDPOINT = 'https://principles-website.pages.dev/api/product-telemetry/snapshot';
|
|
10
|
+
export const PRODUCT_TELEMETRY_TIMEOUT_MS = 8000;
|
|
11
|
+
export const PRODUCT_TELEMETRY_MAX_BODY_BYTES = 4096;
|
|
12
|
+
function defaultFetchFn(url, init) {
|
|
13
|
+
// fetch must be invoked against globalThis inside workers/gateway runtimes
|
|
14
|
+
// ("Illegal invocation" otherwise — same constraint as website relay-core).
|
|
15
|
+
return fetch.call(globalThis, url, init);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Export one snapshot. Never throws; every failure maps to a coarse code
|
|
19
|
+
* with a retryable hint the service uses for backoff.
|
|
20
|
+
*/
|
|
21
|
+
export async function exportSnapshot(args) {
|
|
22
|
+
const endpoint = args.endpoint ?? DEFAULT_PRODUCT_TELEMETRY_ENDPOINT;
|
|
23
|
+
const timeoutMs = args.timeoutMs ?? PRODUCT_TELEMETRY_TIMEOUT_MS;
|
|
24
|
+
const fetchFn = args.fetchFn ?? defaultFetchFn;
|
|
25
|
+
const body = JSON.stringify(args.snapshot);
|
|
26
|
+
if (Buffer.byteLength(body, 'utf8') > PRODUCT_TELEMETRY_MAX_BODY_BYTES) {
|
|
27
|
+
// Safety net: the strict schema keeps snapshots far below 4 KB; a
|
|
28
|
+
// violation here is a programmer error, never worth a network round trip.
|
|
29
|
+
return { ok: false, code: 'invalid_response', retryable: false };
|
|
30
|
+
}
|
|
31
|
+
const controller = new AbortController();
|
|
32
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetchFn(endpoint, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: { 'content-type': 'application/json' },
|
|
37
|
+
body,
|
|
38
|
+
signal: controller.signal,
|
|
39
|
+
});
|
|
40
|
+
const { status } = response;
|
|
41
|
+
if (status >= 200 && status < 300) {
|
|
42
|
+
return { ok: true, status };
|
|
43
|
+
}
|
|
44
|
+
if (status === 400)
|
|
45
|
+
return { ok: false, code: 'http_400', retryable: false };
|
|
46
|
+
if (status === 429)
|
|
47
|
+
return { ok: false, code: 'http_429', retryable: true };
|
|
48
|
+
if (status >= 500)
|
|
49
|
+
return { ok: false, code: 'http_5xx', retryable: true };
|
|
50
|
+
return { ok: false, code: 'http_unexpected_status', retryable: false };
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
54
|
+
return { ok: false, code: 'timeout', retryable: true };
|
|
55
|
+
}
|
|
56
|
+
// fetch rejects with TypeError on DNS failure / connection refused /
|
|
57
|
+
// TLS errors — indistinguishable at this boundary and equally retryable.
|
|
58
|
+
return { ok: false, code: 'network_error', retryable: true };
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* First failure retries after 1h; any further failure within 24h backs off 6h.
|
|
66
|
+
* Combined with the service's dailyAttemptCount hard cap this bounds a bad day
|
|
67
|
+
* to at most 5 attempts (0h/1h/7h/13h/19h), independent of clock skew.
|
|
68
|
+
*/
|
|
69
|
+
export function nextRetryDelayMs(previousFailureWithin24h) {
|
|
70
|
+
return previousFailureWithin24h ? 6 * 60 * 60 * 1000 : 60 * 60 * 1000;
|
|
71
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable-fact milestone readers — Anonymous Product Telemetry v1
|
|
3
|
+
* (PRI-598, SPEC §21-§31; review remediation: tri-state facts).
|
|
4
|
+
*
|
|
5
|
+
* Read-only derivation of the six milestone facts from the authoritative
|
|
6
|
+
* durable sources verified in the Phase 0 authority matrix
|
|
7
|
+
* (docs/audit/anonymous-product-telemetry-feasibility.md §4):
|
|
8
|
+
*
|
|
9
|
+
* initialized <ws>/.pd/state.db schema_version populated
|
|
10
|
+
* painObserved <ws>/.state/trajectory.db pain_events EXISTS
|
|
11
|
+
* principleObserved principle tree ledger non-empty OR state.db principle_candidates EXISTS
|
|
12
|
+
* activationObserved state.db activations EXISTS
|
|
13
|
+
* presenceReceiptObserved state.db principle_applications level='presence' EXISTS
|
|
14
|
+
* effectReceiptObserved state.db principle_applications level='effect' EXISTS
|
|
15
|
+
*
|
|
16
|
+
* Readers never throw (telemetry must not break PD). Facts are TRI-STATE
|
|
17
|
+
* ("Unknown ≠ false"):
|
|
18
|
+
*
|
|
19
|
+
* true = source evaluable AND evidence observed
|
|
20
|
+
* false = source evaluable AND definitively no evidence (e.g. the DB file
|
|
21
|
+
* does not exist at all — absence of any record IS the answer)
|
|
22
|
+
* null = source not currently evaluable (file exists but unreadable,
|
|
23
|
+
* required table missing in an old schema, receipt collection
|
|
24
|
+
* disabled) — recorded in local-only notes, never exported as
|
|
25
|
+
* "observed false"
|
|
26
|
+
*
|
|
27
|
+
* Per-milestone evaluability (not all-or-nothing): each milestone consults
|
|
28
|
+
* only ITS authority/fallback sources; e.g. a malformed principle ledger
|
|
29
|
+
* does not make `initialized` unknown while state.db is readable.
|
|
30
|
+
* `principleObserved` combines its two evidence populations (pipeline
|
|
31
|
+
* candidates ∪ tree principles) with a three-valued OR: either source
|
|
32
|
+
* observing evidence yields true, false only when BOTH populations are
|
|
33
|
+
* definitively empty, and null whenever a population is unknown and the
|
|
34
|
+
* other did not observe evidence (Kleene OR — an evaluated-empty source
|
|
35
|
+
* can never resolve another source's unknown).
|
|
36
|
+
*
|
|
37
|
+
* `initializationFailed` can only be TRUE from an explicit readable-DB fact
|
|
38
|
+
* (state.db exists, opens, and its schema is definitively not initialized).
|
|
39
|
+
* An unreadable DB is an UNKNOWN, never a reported initialization failure —
|
|
40
|
+
* a read failure must not fabricate release-health signal.
|
|
41
|
+
*
|
|
42
|
+
* Receipt gating: when the `principle_receipt_ledger` flag is disabled, no
|
|
43
|
+
* receipt rows are being written, so absence of rows proves nothing — the
|
|
44
|
+
* receipt milestones render `null` (unavailable), not false.
|
|
45
|
+
*
|
|
46
|
+
* Every connection is closed before return (leaked handles would block
|
|
47
|
+
* Windows file cleanup and accumulate in the gateway process).
|
|
48
|
+
*/
|
|
49
|
+
import type { ProductTelemetryMilestoneInput, TelemetryFact } from '@principles/core/runtime-v2';
|
|
50
|
+
export interface MilestoneFacts extends ProductTelemetryMilestoneInput {
|
|
51
|
+
initializationFailed: TelemetryFact;
|
|
52
|
+
}
|
|
53
|
+
export interface MilestoneReadResult {
|
|
54
|
+
facts: MilestoneFacts;
|
|
55
|
+
/** Local-only observability notes (never exported). */
|
|
56
|
+
notes: string[];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Derive milestone facts for one workspace. Never throws.
|
|
60
|
+
*/
|
|
61
|
+
export declare function readMilestoneFacts(workspaceDir: string): MilestoneReadResult;
|