fraim-hub 2.0.345 → 2.0.348

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.
@@ -4,10 +4,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.COMPLETION_END = exports.COMPLETION_START = exports.UnsupportedRunHistoryError = exports.sourcePathKey = void 0;
7
+ exports.validateDeferredAttempt = validateDeferredAttempt;
8
+ exports.validateDeferredWork = validateDeferredWork;
7
9
  exports.cycleRetrospectivePath = cycleRetrospectivePath;
8
10
  exports.validateProcessedSources = validateProcessedSources;
9
11
  exports.canonicalJournal = canonicalJournal;
10
12
  exports.readRunJournal = readRunJournal;
13
+ exports.readRunReservations = readRunReservations;
11
14
  exports.readSourceReservations = readSourceReservations;
12
15
  exports.normalizeSourceParticipants = normalizeSourceParticipants;
13
16
  exports.sourceAuthorAllowed = sourceAuthorAllowed;
@@ -16,6 +19,34 @@ const node_path_1 = __importDefault(require("node:path"));
16
19
  const node_fs_1 = __importDefault(require("node:fs"));
17
20
  const node_crypto_1 = require("node:crypto");
18
21
  const artifact_paths_1 = require("../artifact-paths");
22
+ const corpus_audit_1 = require("./corpus-audit");
23
+ const outcome_types_1 = require("./outcome-types");
24
+ function validateDeferredAttempt(value) {
25
+ if (!keys(value, ['at', 'evidenceHash', 'deferred', 'reason']) || !text(value.at) || !Number.isFinite(Date.parse(value.at)) || !hash(value.evidenceHash))
26
+ throw new Error('invalid-deferred-attempt');
27
+ // Older journals embedded the checkpoint snapshot; keep accepting those exact records.
28
+ if (value.deferred !== undefined && value.reason === undefined)
29
+ validateDeferredWork(value.deferred);
30
+ else if (value.deferred !== undefined || !text(value.reason))
31
+ throw new Error('invalid-deferred-attempt');
32
+ }
33
+ function validateDeferredWork(value) {
34
+ if (!keys(value, ['actions', 'sources', 'corpusComplete', 'corpusErrors']) || value.corpusComplete !== false || !Array.isArray(value.actions) || !Array.isArray(value.sources))
35
+ throw new Error('invalid-deferred-work');
36
+ if (value.corpusErrors !== undefined && (!Array.isArray(value.corpusErrors) || value.corpusErrors.some(item => !keys(item, ['path', 'reason']) || !text(item.path) || !node_path_1.default.isAbsolute(item.path) || !text(item.reason))))
37
+ throw new Error('invalid-deferred-work');
38
+ const actions = new Set(), sources = new Set();
39
+ for (const item of value.actions) {
40
+ if (!keys(item, ['actionId', 'revision', 'digest', 'reason']) || !text(item.actionId) || !Number.isInteger(item.revision) || item.revision < 1 || !hash(item.digest) || !text(item.reason) || actions.has(item.actionId))
41
+ throw new Error('invalid-deferred-work');
42
+ actions.add(item.actionId);
43
+ }
44
+ for (const item of value.sources) {
45
+ if (!keys(item, ['path', 'owner', 'reason', 'sha256']) || !text(item.path) || !node_path_1.default.isAbsolute(item.path) || /[\x00-\x1f]/.test(item.path) || !ownerValid(item.owner) || !text(item.reason) || (item.sha256 !== undefined && !hash(item.sha256)) || sources.has((0, exports.sourcePathKey)(item.path)))
46
+ throw new Error('invalid-deferred-work');
47
+ sources.add((0, exports.sourcePathKey)(item.path));
48
+ }
49
+ }
19
50
  const sourcePathKey = (file) => {
20
51
  const value = node_path_1.default.resolve(file).replace(/\\/g, '/');
21
52
  return process.platform === 'win32' ? value.toLowerCase() : value;
@@ -144,9 +175,10 @@ function readRunJournal(file) {
144
175
  }
145
176
  return { events, lastHash, format: 'current' };
146
177
  }
147
- /** Only exact, hash-chain-verified current archive plans reserve files. Older plans are outside this lifecycle. */
148
- function readSourceReservations(runDirectories, excludingRunFile) {
178
+ /** Current archive plans and prepared assessments share one verified reservation inventory. */
179
+ function readRunReservations(runDirectories, excludingRunFile) {
149
180
  const reserved = new Set();
181
+ const assessmentKeys = new Set();
150
182
  for (const directory of new Set(runDirectories.map(dir => node_path_1.default.resolve(dir)))) {
151
183
  let names;
152
184
  try {
@@ -167,6 +199,23 @@ function readSourceReservations(runDirectories, excludingRunFile) {
167
199
  const journal = readRunJournal(file);
168
200
  if (journal.format === 'historical')
169
201
  continue;
202
+ const assessmentSources = new Map();
203
+ for (const event of journal.events) {
204
+ if (event.payload.type === 'assessment-prepared') {
205
+ const { record, sourcePaths } = event.payload.data;
206
+ (0, outcome_types_1.validateOutcomeAssessmentRecord)(record);
207
+ if (!Array.isArray(sourcePaths) || sourcePaths.length !== record.sourceRefs.length || sourcePaths.some(file => typeof file !== 'string' || !node_path_1.default.isAbsolute(file)))
208
+ throw new Error('invalid-assessment-sources');
209
+ assessmentSources.set(record.digest, { paths: sourcePaths, key: (0, outcome_types_1.assessmentKey)(record) });
210
+ }
211
+ else if (event.payload.type === 'assessment-committed')
212
+ assessmentSources.delete(event.payload.data.digest);
213
+ }
214
+ for (const reservation of assessmentSources.values()) {
215
+ assessmentKeys.add(reservation.key);
216
+ for (const source of reservation.paths)
217
+ reserved.add((0, exports.sourcePathKey)(source));
218
+ }
170
219
  for (const event of journal.events)
171
220
  if (event.payload.type === 'source-completion') {
172
221
  for (const source of event.payload.data) {
@@ -182,7 +231,10 @@ function readSourceReservations(runDirectories, excludingRunFile) {
182
231
  }
183
232
  }
184
233
  }
185
- return [...reserved].sort();
234
+ return { sources: [...reserved].sort(), assessmentKeys: [...assessmentKeys].sort() };
235
+ }
236
+ function readSourceReservations(runDirectories, excludingRunFile) {
237
+ return readRunReservations(runDirectories, excludingRunFile).sources;
186
238
  }
187
239
  exports.COMPLETION_START = '<!-- fraim-sleep-decisions:v1 -->';
188
240
  exports.COMPLETION_END = '<!-- /fraim-sleep-decisions -->';
@@ -233,7 +285,7 @@ function decodeCompletion(markdown, expected = {}) {
233
285
  catch {
234
286
  fail();
235
287
  }
236
- if (!keys(state, ['schemaVersion', 'runId', 'owner', 'finalizationState', 'decisions', 'sourceDispositions', 'cleanup', 'reconciliations', 'retrospective', 'processedSources', 'sourceParticipants']) || state.schemaVersion !== 1 || !text(state.runId) || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(state.runId) || !ownerValid(state.owner)
288
+ if (!keys(state, ['schemaVersion', 'runId', 'owner', 'finalizationState', 'decisions', 'sourceDispositions', 'cleanup', 'reconciliations', 'retrospective', 'processedSources', 'sourceParticipants', 'corpusAudit', 'reconciliationHistory', 'deferred', 'deferredHistory', 'deferredAttempts', 'outcomeAssessments']) || state.schemaVersion !== 1 || !text(state.runId) || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(state.runId) || !ownerValid(state.owner)
237
289
  || !['pending', 'complete'].includes(state.finalizationState) || !Array.isArray(state.decisions) || !Array.isArray(state.sourceDispositions) || !Array.isArray(state.cleanup))
238
290
  fail();
239
291
  if ((expected.runId && expected.runId !== state.runId) || (expected.owner && !sameOwner(expected.owner, state.owner)))
@@ -248,8 +300,31 @@ function decodeCompletion(markdown, expected = {}) {
248
300
  fail();
249
301
  if (state.processedSources !== undefined)
250
302
  validateProcessedSources(state.processedSources);
303
+ if (state.outcomeAssessments !== undefined) {
304
+ if (!Array.isArray(state.outcomeAssessments))
305
+ fail();
306
+ const digests = new Set();
307
+ for (const assessment of state.outcomeAssessments) {
308
+ (0, outcome_types_1.validateOutcomeAssessmentRecord)(assessment);
309
+ if (assessment.runId !== state.runId || digests.has(assessment.digest))
310
+ fail();
311
+ digests.add(assessment.digest);
312
+ }
313
+ }
251
314
  if (state.sourceParticipants !== undefined)
252
315
  normalizeSourceParticipants(state.sourceParticipants, state.owner);
316
+ if (state.deferred !== undefined)
317
+ validateDeferredWork(state.deferred);
318
+ if (state.deferredHistory !== undefined) {
319
+ if (!Array.isArray(state.deferredHistory))
320
+ fail();
321
+ state.deferredHistory.forEach(validateDeferredWork);
322
+ }
323
+ if (state.deferredAttempts !== undefined) {
324
+ if (!Array.isArray(state.deferredAttempts))
325
+ fail();
326
+ state.deferredAttempts.forEach(validateDeferredAttempt);
327
+ }
253
328
  const ids = new Set();
254
329
  for (const item of state.decisions) {
255
330
  if (!keys(item, ['actionId', 'revision', 'digest', 'owner', 'claimKey', 'evidenceFingerprint', 'sourceRefs', 'decision', 'publicationRef', 'target', 'dependencies']) || !text(item.actionId) || ids.has(item.actionId) || !Number.isInteger(item.revision) || item.revision < 1 || !hash(item.digest) || !ownerValid(item.owner)
@@ -281,6 +356,50 @@ function decodeCompletion(markdown, expected = {}) {
281
356
  fail();
282
357
  }
283
358
  const decisions = new Map(state.decisions.map((item) => [item.actionId, item]));
359
+ if (state.deferred?.actions.some((item) => decisions.has(item.actionId)))
360
+ fail();
361
+ if (state.corpusAudit !== undefined) {
362
+ try {
363
+ (0, corpus_audit_1.validateCorpusAudit)(state.corpusAudit);
364
+ }
365
+ catch {
366
+ fail();
367
+ }
368
+ for (const item of state.corpusAudit.dispositions)
369
+ for (const binding of item.actionBindings) {
370
+ const decision = decisions.get(binding.actionId);
371
+ if (!decision || decision.digest !== binding.digest || decision.revision !== binding.revision || decision.decision.reviewer !== state.corpusAudit.reviewer)
372
+ fail();
373
+ }
374
+ }
375
+ if (state.reconciliationHistory !== undefined) {
376
+ if (!Array.isArray(state.reconciliationHistory))
377
+ fail();
378
+ const seen = new Set();
379
+ const validRef = (ref) => keys(ref, ['owner', 'relativePath', 'entryId', 'learningSubject']) && ownerValid(ref.owner)
380
+ && typeof ref.relativePath === 'string' && ref.relativePath.startsWith('learnings/') && !ref.relativePath.includes('\\') && !ref.relativePath.includes(':')
381
+ && !ref.relativePath.split('/').some((part) => !part || part === '.' || part === '..') && /^L-[a-f0-9]{10}$/.test(ref.entryId)
382
+ && (ref.learningSubject === undefined || text(ref.learningSubject));
383
+ for (const history of state.reconciliationHistory) {
384
+ const decision = decisions.get(history.actionId);
385
+ if (!keys(history, ['actionId', 'revision', 'digest', 'target', 'resultHash', 'occurrences']) || !decision || seen.has(history.actionId)
386
+ || decision.decision.action !== 'approve' || history.revision !== decision.revision || history.digest !== decision.digest
387
+ || !validRef(history.target) || canonicalJournal(history.target) !== canonicalJournal(decision.target) || !hash(history.resultHash)
388
+ || !Array.isArray(history.occurrences) || !history.occurrences.length)
389
+ fail();
390
+ seen.add(history.actionId);
391
+ const locations = new Set();
392
+ for (const occurrence of history.occurrences) {
393
+ if (!keys(occurrence, ['ref', 'sha256', 'before']) || !validRef(occurrence.ref) || occurrence.ref.entryId !== history.target.entryId
394
+ || !text(occurrence.before) || !hash(occurrence.sha256) || (0, node_crypto_1.createHash)('sha256').update(occurrence.before).digest('hex') !== occurrence.sha256)
395
+ fail();
396
+ const location = canonicalJournal([occurrence.ref.owner, occurrence.ref.relativePath]);
397
+ if (locations.has(location))
398
+ fail();
399
+ locations.add(location);
400
+ }
401
+ }
402
+ }
284
403
  const outcome = (item) => item.decision.action === 'approve' ? 'applied' : 'rejected';
285
404
  const dispositions = new Set();
286
405
  for (const source of state.sourceDispositions) {
@@ -0,0 +1,205 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.corpusEntryKey = corpusEntryKey;
7
+ exports.collectLearningCorpus = collectLearningCorpus;
8
+ exports.validateCorpusAudit = validateCorpusAudit;
9
+ const node_fs_1 = __importDefault(require("node:fs"));
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ const node_crypto_1 = require("node:crypto");
12
+ const owner_identity_1 = require("./owner-identity");
13
+ const { learningEntryBlocks, learningEntryIdentity, isLearningEntryId } = require('./entry-format');
14
+ const stable = (value) => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
15
+ ? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item);
16
+ const hash = (value) => (0, node_crypto_1.createHash)('sha256').update(value).digest('hex');
17
+ const keys = (value, allowed) => !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).every(key => allowed.includes(key));
18
+ function corpusEntryKey(entry) {
19
+ return (0, owner_identity_1.ownerKey)(entry.ref.owner) + ':' + entry.ref.relativePath + ':' + (entry.ref.entryId ?? `unassigned:${entry.occurrence}`);
20
+ }
21
+ function corpusFingerprint(homes, entries) { return hash(stable({ homes, entries })); }
22
+ /** Only persisted identity and bytes are mechanical; audience and semantic conflicts require review. */
23
+ function collectLearningCorpus(homes, subjects, readerRoots = []) {
24
+ const entries = [], errors = [], seen = new Set();
25
+ const identities = [...new Map(homes.map(home => [(0, owner_identity_1.ownerKey)(home.owner), home.owner])).values()].sort((a, b) => (0, owner_identity_1.ownerKey)(a).localeCompare((0, owner_identity_1.ownerKey)(b)));
26
+ const escaped = [...new Set([...subjects, 'org'])].map(subject => subject.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
27
+ const family = new RegExp(`^(?:${escaped.join('|')})-(?:[a-z][a-z-]*-)?(?:preferences|mistake-patterns|validated-patterns|manager-coaching)(\\.cold)?\\.md$`);
28
+ const excluded = new Set(['raw', 'retrospectives', 'archive', 'review-queue', 'review-archive', 'review-evidence']);
29
+ for (const home of [...homes].sort((a, b) => (0, owner_identity_1.ownerKey)(a.owner).localeCompare((0, owner_identity_1.ownerKey)(b.owner)))) {
30
+ const walk = (directory) => {
31
+ try {
32
+ if (node_fs_1.default.lstatSync(directory).isSymbolicLink())
33
+ throw new Error('symlink-corpus-not-followed');
34
+ for (const item of node_fs_1.default.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
35
+ const file = node_path_1.default.join(directory, item.name);
36
+ if (item.isSymbolicLink()) {
37
+ if (!excluded.has(item.name))
38
+ errors.push({ path: file.replace(/\\/g, '/'), reason: 'symlink-corpus-not-followed' });
39
+ continue;
40
+ }
41
+ if (item.isDirectory()) {
42
+ if (!excluded.has(item.name))
43
+ walk(file);
44
+ continue;
45
+ }
46
+ if (!family.test(item.name))
47
+ continue;
48
+ try {
49
+ if (item.isSymbolicLink())
50
+ throw new Error('symlink-corpus-not-followed');
51
+ const real = node_fs_1.default.realpathSync(file);
52
+ const key = process.platform === 'win32' ? real.toLowerCase() : real;
53
+ if (seen.has(key))
54
+ continue;
55
+ seen.add(key);
56
+ const relativePath = node_path_1.default.relative(home.contentRoot, file).replace(/\\/g, '/');
57
+ (0, owner_identity_1.ownerArtifactPath)(home, relativePath);
58
+ const body = node_fs_1.default.readFileSync(file, 'utf8');
59
+ if (/^<{7}/m.test(body))
60
+ throw new Error('corpus-merge-conflict');
61
+ const blocks = learningEntryBlocks(body), ids = new Set();
62
+ let unassigned = 0;
63
+ for (const block of blocks) {
64
+ // Match the runtime reader's handwritten identity contract.
65
+ // Approved mutations still use the author's narrower generated-ID schema.
66
+ const { id, ambiguous } = learningEntryIdentity(block.text);
67
+ if (ambiguous || (id && ids.has(id))
68
+ || !/^#{2,6}\s+\[P-(CRITICAL|HIGH|MED|LOW)\]\s+\S/.test(block.text)) {
69
+ errors.push({ path: file.replace(/\\/g, '/'), reason: 'ambiguous-corpus-entry' });
70
+ continue;
71
+ }
72
+ if (id)
73
+ ids.add(id);
74
+ entries.push({ ref: { owner: home.owner, relativePath, ...(id ? { entryId: id } : {}) }, sha256: hash(block.text),
75
+ ...(!id ? { occurrence: unassigned++ } : {}) });
76
+ }
77
+ }
78
+ catch (error) {
79
+ errors.push({ path: file.replace(/\\/g, '/'), reason: error.code || error.message });
80
+ }
81
+ }
82
+ }
83
+ catch (error) {
84
+ if (error.code !== 'ENOENT')
85
+ errors.push({ path: directory.replace(/\\/g, '/'), reason: error.code || error.message });
86
+ }
87
+ };
88
+ try {
89
+ walk((0, owner_identity_1.ownerArtifactPath)(home, 'learnings'));
90
+ }
91
+ catch (error) {
92
+ errors.push({ path: home.contentRoot, reason: error.message });
93
+ }
94
+ }
95
+ // Legacy reader locations are explicitly discoverable but cannot acquire a
96
+ // writable OwnerRef just by being present. Report every unowned family file.
97
+ const inspectReaderRoot = (directory) => {
98
+ try {
99
+ if (node_fs_1.default.lstatSync(directory).isSymbolicLink())
100
+ throw new Error('symlink-corpus-not-followed');
101
+ for (const item of node_fs_1.default.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
102
+ const file = node_path_1.default.join(directory, item.name);
103
+ if (excluded.has(item.name))
104
+ continue;
105
+ if (item.isSymbolicLink()) {
106
+ errors.push({ path: file.replace(/\\/g, '/'), reason: 'symlink-corpus-not-followed' });
107
+ continue;
108
+ }
109
+ if (item.isDirectory()) {
110
+ inspectReaderRoot(file);
111
+ continue;
112
+ }
113
+ if (!family.test(item.name))
114
+ continue;
115
+ try {
116
+ const real = node_fs_1.default.realpathSync(file), key = process.platform === 'win32' ? real.toLowerCase() : real;
117
+ if (seen.has(key))
118
+ continue;
119
+ seen.add(key);
120
+ errors.push({ path: file.replace(/\\/g, '/'), reason: 'reader-live-home-unowned' });
121
+ }
122
+ catch (error) {
123
+ errors.push({ path: file.replace(/\\/g, '/'), reason: error.code || error.message });
124
+ }
125
+ }
126
+ }
127
+ catch (error) {
128
+ if (error.code !== 'ENOENT')
129
+ errors.push({ path: directory.replace(/\\/g, '/'), reason: error.code || error.message });
130
+ }
131
+ };
132
+ for (const directory of [...new Set(readerRoots)].sort()) {
133
+ // An explicit configured alias of an owned learning root is already scanned.
134
+ try {
135
+ const key = (file) => { const real = node_fs_1.default.realpathSync(file); return process.platform === 'win32' ? real.toLowerCase() : real; };
136
+ const real = key(directory);
137
+ if (homes.some(home => { try {
138
+ return key(node_path_1.default.join(home.contentRoot, 'learnings')) === real;
139
+ }
140
+ catch {
141
+ return false;
142
+ } }))
143
+ continue;
144
+ }
145
+ catch { /* Missing/unreadable roots are handled by the scanner below. */ }
146
+ inspectReaderRoot(directory);
147
+ }
148
+ entries.sort((a, b) => corpusEntryKey(a).localeCompare(corpusEntryKey(b)));
149
+ const groups = new Map();
150
+ for (const entry of entries)
151
+ if (entry.ref.entryId)
152
+ groups.set(entry.ref.entryId, [...(groups.get(entry.ref.entryId) || []), entry]);
153
+ const duplicates = [...groups].filter(([, occurrences]) => occurrences.length > 1).map(([entryId, occurrences]) => ({ entryId, occurrences })).sort((a, b) => a.entryId.localeCompare(b.entryId));
154
+ return { schemaVersion: 1, homes: identities, entries, duplicates, fingerprint: corpusFingerprint(identities, entries), complete: !errors.length, errors };
155
+ }
156
+ /** Shared by journal replay and the strict compact receipt decoder. No filesystem access. */
157
+ function validateCorpusAudit(audit, reviewer) {
158
+ const fail = () => { throw new Error('invalid-corpus-audit'); };
159
+ if (!keys(audit, ['snapshot', 'reviewer', 'dispositions', 'partial']) || typeof audit.reviewer !== 'string' || !audit.reviewer.trim() || (reviewer && audit.reviewer !== reviewer) || (audit.partial !== undefined && audit.partial !== true)
160
+ || !keys(audit.snapshot, ['schemaVersion', 'homes', 'entries', 'duplicates', 'fingerprint', 'complete', 'errors']) || audit.snapshot.schemaVersion !== 1 || typeof audit.snapshot.complete !== 'boolean' || (!audit.partial && !audit.snapshot.complete) || !Array.isArray(audit.snapshot.errors) || (!audit.partial && audit.snapshot.errors.length)
161
+ || audit.snapshot.errors.some(item => !keys(item, ['path', 'reason']) || typeof item.path !== 'string' || !node_path_1.default.isAbsolute(item.path) || typeof item.reason !== 'string' || !item.reason.trim())
162
+ || !Array.isArray(audit.snapshot.homes) || !Array.isArray(audit.snapshot.entries) || !Array.isArray(audit.dispositions))
163
+ fail();
164
+ const snapshot = audit.snapshot;
165
+ for (const owner of snapshot.homes)
166
+ (0, owner_identity_1.validateOwner)(owner);
167
+ if (new Set(snapshot.homes.map(owner_identity_1.ownerKey)).size !== snapshot.homes.length || snapshot.fingerprint !== corpusFingerprint(snapshot.homes, snapshot.entries))
168
+ fail();
169
+ const entries = new Map();
170
+ for (const entry of snapshot.entries) {
171
+ if (!keys(entry, ['ref', 'sha256', 'occurrence']) || !keys(entry.ref, ['owner', 'relativePath', 'entryId']))
172
+ fail();
173
+ (0, owner_identity_1.validateOwner)(entry.ref?.owner);
174
+ if (!snapshot.homes.some(owner => (0, owner_identity_1.ownerKey)(owner) === (0, owner_identity_1.ownerKey)(entry.ref.owner))
175
+ || (entry.ref.entryId === undefined ? !Number.isSafeInteger(entry.occurrence) || entry.occurrence < 0 : !isLearningEntryId(entry.ref.entryId) || entry.occurrence !== undefined)
176
+ || !/^learnings\//.test(entry.ref.relativePath) || /[\\:]/.test(entry.ref.relativePath) || entry.ref.relativePath.split('/').some(part => !part || part === '.' || part === '..')
177
+ || !/^[a-f0-9]{64}$/.test(entry.sha256) || entries.has(corpusEntryKey(entry)))
178
+ fail();
179
+ entries.set(corpusEntryKey(entry), entry);
180
+ }
181
+ const groups = new Map();
182
+ for (const entry of snapshot.entries)
183
+ if (entry.ref.entryId)
184
+ groups.set(entry.ref.entryId, [...(groups.get(entry.ref.entryId) || []), entry]);
185
+ const duplicates = [...groups].filter(([, occurrences]) => occurrences.length > 1).map(([entryId, occurrences]) => ({ entryId, occurrences })).sort((a, b) => a.entryId.localeCompare(b.entryId));
186
+ if (stable(snapshot.duplicates) !== stable(duplicates))
187
+ fail();
188
+ if (audit.dispositions.length !== entries.size)
189
+ fail();
190
+ const seen = new Set();
191
+ for (const item of audit.dispositions) {
192
+ if (!keys(item, ['ref', 'sha256', 'occurrence', 'outcome', 'rationale', 'actionIds', 'actionBindings']) || !keys(item.ref, ['owner', 'relativePath', 'entryId']))
193
+ fail();
194
+ const key = corpusEntryKey(item);
195
+ if (seen.has(key) || entries.get(key)?.sha256 !== item.sha256 || entries.get(key)?.occurrence !== item.occurrence || !['retained', 'action'].includes(item.outcome)
196
+ || typeof item.rationale !== 'string' || !item.rationale.trim() || !Array.isArray(item.actionIds) || new Set(item.actionIds).size !== item.actionIds.length
197
+ || (item.outcome === 'retained' ? item.actionIds.length !== 0 : item.actionIds.length === 0)
198
+ || !Array.isArray(item.actionBindings) || item.actionBindings.length !== item.actionIds.length)
199
+ fail();
200
+ for (const [index, binding] of item.actionBindings.entries())
201
+ if (!keys(binding, ['actionId', 'revision', 'digest']) || binding.actionId !== item.actionIds[index] || !Number.isInteger(binding.revision) || binding.revision < 1 || !/^[a-f0-9]{64}$/.test(binding.digest))
202
+ fail();
203
+ seen.add(key);
204
+ }
205
+ }
@@ -1,5 +1,14 @@
1
1
  'use strict';
2
2
 
3
+ const LEARNING_ENTRY_ID_LINE = /^\*\*Id\*\*:\s*(L-[0-9A-Za-z]{6,24})\s*$/;
4
+ function isLearningEntryId(value) { return typeof value === 'string' && /^L-[0-9A-Za-z]{6,24}$/.test(value); }
5
+ /** Reader/corpus identity accepts stable handwritten IDs; approved mutation IDs remain narrower below. */
6
+ function learningEntryIdentity(content) {
7
+ const declarations = content.split(/\r?\n/).map(line => line.trim()).filter(line => /^\*\*Id\*\*:/i.test(line));
8
+ const id = declarations[0]?.match(LEARNING_ENTRY_ID_LINE)?.[1] ?? null;
9
+ return { id: declarations.length === 1 ? id : null, ambiguous: declarations.length > 0 && (!id || declarations.length !== 1) };
10
+ }
11
+
3
12
  // Read the shared author's current entry format for identity and update guards.
4
13
  function learningEntryBlocks(body) {
5
14
  const headings = [...body.matchAll(/^#{2,6}\s+\[P-[A-Z]+\].*$/gm)];
@@ -24,4 +33,4 @@ function learningEntryMetadata(content) {
24
33
  return metadata;
25
34
  }
26
35
 
27
- module.exports = { learningEntryMetadata, learningEntryBlocks };
36
+ module.exports = { learningEntryMetadata, learningEntryBlocks, learningEntryIdentity, isLearningEntryId, LEARNING_ENTRY_ID_LINE };
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.matchesOutcomeScope = matchesOutcomeScope;
4
+ exports.assessmentKey = assessmentKey;
5
+ exports.assessmentContent = assessmentContent;
6
+ exports.outcomeAssessmentDigest = outcomeAssessmentDigest;
7
+ exports.validateOutcomeAssessmentInput = validateOutcomeAssessmentInput;
8
+ exports.validateOutcomeAssessmentRecord = validateOutcomeAssessmentRecord;
9
+ exports.summarizeLearningOutcomes = summarizeLearningOutcomes;
10
+ const node_crypto_1 = require("node:crypto");
11
+ const owner_identity_1 = require("./owner-identity");
12
+ const { isLearningEntryId } = require('./entry-format');
13
+ function matchesOutcomeScope(record, scope) {
14
+ return (scope.key === undefined || assessmentKey(record) === scope.key)
15
+ && (scope.principal === undefined || record.learning.learningSubject === scope.principal)
16
+ && (scope.owners === undefined || scope.owners.some(owner => (0, owner_identity_1.ownerKey)(owner) === (0, owner_identity_1.ownerKey)(record.learning.owner)));
17
+ }
18
+ const canonical = (value) => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
19
+ ? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item);
20
+ const text = (value) => typeof value === 'string' && !!value.trim() && !/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(value);
21
+ const hash = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
22
+ const relative = (value) => text(value) && !/[\\:\x00-\x1f]/.test(value) && value.split('/').every(part => !!part && part !== '.' && part !== '..');
23
+ const date = (value) => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z)?$/.test(value)
24
+ && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value.slice(0, 10);
25
+ /** Semantic identity comes from Sleep; paths are provenance, never a title or move-sensitive join. */
26
+ function assessmentKey(input) {
27
+ return canonical([(0, owner_identity_1.ownerKey)(input.learning.owner), input.learning.learningSubject, input.learning.entryId, input.incidentId]);
28
+ }
29
+ function assessmentContent(input) {
30
+ return canonical({ learning: input.learning, incidentId: input.incidentId, observedAt: input.observedAt, outcome: input.outcome,
31
+ reportedHelp: input.reportedHelp, explicitlySkipped: input.explicitlySkipped,
32
+ sourceRefs: [...input.sourceRefs].sort((a, b) => canonical(a).localeCompare(canonical(b))), rationale: input.rationale,
33
+ guidanceExistedAt: input.guidanceExistedAt });
34
+ }
35
+ function outcomeAssessmentDigest(record) {
36
+ return (0, node_crypto_1.createHash)('sha256').update(canonical({ content: assessmentContent(record), revision: record.revision,
37
+ expectedPriorDigest: record.expectedPriorDigest, reviewer: record.reviewer })).digest('hex');
38
+ }
39
+ function validateOutcomeAssessmentInput(input) {
40
+ if (!input || !input.learning)
41
+ throw new Error('invalid-outcome-assessment');
42
+ (0, owner_identity_1.validateOwner)(input.learning.owner);
43
+ if (!relative(input.learning.relativePath) || !isLearningEntryId(input.learning.entryId)
44
+ || !text(input.learning.learningSubject) || !text(input.incidentId) || input.incidentId.length > 512
45
+ || !date(input.observedAt) || !date(input.guidanceExistedAt)
46
+ || !['success', 'miss', 'unknown'].includes(input.outcome) || typeof input.reportedHelp !== 'boolean'
47
+ || typeof input.explicitlySkipped !== 'boolean' || !text(input.rationale)
48
+ || (input.expectedPriorDigest !== null && !hash(input.expectedPriorDigest))
49
+ || !Array.isArray(input.sourceRefs) || !input.sourceRefs.length)
50
+ throw new Error('invalid-outcome-assessment');
51
+ if (input.reportedHelp && input.outcome !== 'success' || input.explicitlySkipped && input.outcome !== 'miss')
52
+ throw new Error('invalid-outcome-qualification');
53
+ if (Date.parse(input.guidanceExistedAt) > Date.parse(input.observedAt))
54
+ throw new Error('guidance-after-incident');
55
+ for (const source of input.sourceRefs) {
56
+ if (!source)
57
+ throw new Error('invalid-assessment-source');
58
+ (0, owner_identity_1.validateOwner)(source.owner);
59
+ if (!relative(source.relativePath) || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(source.sourceId)
60
+ || !hash(source.sha256))
61
+ throw new Error('invalid-assessment-source');
62
+ }
63
+ if (new Set(input.sourceRefs.map(source => canonical([(0, owner_identity_1.ownerKey)(source.owner), source.sourceId]))).size !== input.sourceRefs.length)
64
+ throw new Error('duplicate-assessment-source');
65
+ }
66
+ function validateOutcomeAssessmentRecord(record) {
67
+ validateOutcomeAssessmentInput(record);
68
+ if (!Number.isInteger(record.revision) || record.revision < 1 || !date(record.assessedAt)
69
+ || !text(record.reviewer) || !text(record.runId) || record.digest !== outcomeAssessmentDigest(record))
70
+ throw new Error('invalid-outcome-record');
71
+ }
72
+ function summarizeLearningOutcomes(records, learning) {
73
+ const selected = new Map();
74
+ for (const record of records) {
75
+ if ((0, owner_identity_1.ownerKey)(record.learning.owner) !== (0, owner_identity_1.ownerKey)(learning.owner) || record.learning.entryId !== learning.entryId
76
+ || record.learning.learningSubject !== learning.learningSubject)
77
+ continue;
78
+ validateOutcomeAssessmentRecord(record);
79
+ const key = assessmentKey(record), prior = selected.get(key);
80
+ if (!prior || record.revision > prior.revision)
81
+ selected.set(key, record);
82
+ else if (record.revision === prior.revision && record.digest !== prior.digest)
83
+ throw new Error('assessment-history-conflict');
84
+ }
85
+ if (!selected.size)
86
+ return undefined;
87
+ const current = [...selected.values()], observed = current.map(record => record.observedAt).sort();
88
+ return { success: current.filter(record => record.outcome === 'success').length,
89
+ reportedHelp: current.filter(record => record.outcome === 'success' && record.reportedHelp).length,
90
+ miss: current.filter(record => record.outcome === 'miss').length, unknown: current.filter(record => record.outcome === 'unknown').length,
91
+ firstObservedAt: observed[0], lastObservedAt: observed[observed.length - 1], assessedAt: current.map(record => record.assessedAt).sort().at(-1) };
92
+ }