mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-18 15:50:25 +02:00
feat: group verified capsules into offline retrospective reports
Add deterministic grouping for explicitly selected capsules from one task family. Deduplicate identical snapshots and reject conflicting identities without changing input files or exposing payloads and raw IDs. Keep execution and promotion disabled. Validation: 16 library and 12 CLI checks, scoped scanners, independent code and security review.
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
*
|
||||
* envelope capsule-envelope/v1 contract, redaction, secret canaries
|
||||
* capsule append-only hash-linked journal with five lineages
|
||||
* retrospective offline report-only grouping of selected capsule snapshots
|
||||
* gate static inspection and disabled execution gate, syntactic warnings
|
||||
* replay declared tool effects, fixtures, fail-closed replay, retired effect preload
|
||||
* receipt offline-verifiable capsule receipts
|
||||
@@ -16,6 +17,7 @@ module.exports = {
|
||||
canonical: require('./canonical'),
|
||||
envelope: require('./envelope'),
|
||||
capsule: require('./capsule'),
|
||||
retrospective: require('./retrospective'),
|
||||
gate: require('./gate'),
|
||||
replay: require('./replay'),
|
||||
receipt: require('./receipt'),
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
'use strict';
|
||||
|
||||
/** Read-only retrospective preparation over explicit, quiescent local capsules.
|
||||
* Counts are declarations in the records, never scores or promotion evidence.
|
||||
* Inherits capsule.project's local-reader limits; not hostile-filesystem isolation.
|
||||
*/
|
||||
const capsule = require('./capsule');
|
||||
const { LINEAGES, EFFECT_CLASSES } = require('./envelope');
|
||||
const { hashValue } = require('./canonical');
|
||||
|
||||
const MAX_INPUTS = 100;
|
||||
const SOURCE_FIELDS = ['entry_count', 'root_hash', 'journal_sha256', 'projection_hash'];
|
||||
|
||||
class RetrospectiveError extends Error {
|
||||
constructor(code, message, inputIndex) {
|
||||
super(message);
|
||||
this.name = 'RetrospectiveError';
|
||||
this.code = code;
|
||||
if (inputIndex !== undefined) this.input_index = inputIndex;
|
||||
}
|
||||
}
|
||||
|
||||
function readProjection(dir, index) {
|
||||
try {
|
||||
return capsule.project(dir);
|
||||
} catch {
|
||||
// Reader errors can contain private paths or journal content. No partial
|
||||
// report or unchecked diagnostic content crosses the report boundary.
|
||||
throw new RetrospectiveError('retrospective.invalid_capsule', `capsule at input index ${index} failed verification`, index);
|
||||
}
|
||||
}
|
||||
|
||||
const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
||||
const identity = projection => JSON.stringify([projection.run_id, projection.capsule_id]);
|
||||
|
||||
function summarizeGroup(harnessVersion, projections) {
|
||||
const total = keys => Object.fromEntries(keys.map(key => [key, 0]));
|
||||
const byLineage = total(LINEAGES);
|
||||
const byEffect = total(EFFECT_CLASSES);
|
||||
for (const projection of projections) {
|
||||
for (const key of LINEAGES) byLineage[key] += projection.by_lineage[key];
|
||||
for (const key of EFFECT_CLASSES) byEffect[key] += projection.by_effect_class[key];
|
||||
}
|
||||
return {
|
||||
harness_version: harnessVersion,
|
||||
capsule_count: projections.length,
|
||||
entry_count: projections.reduce((sum, item) => sum + item.entry_count, 0),
|
||||
by_lineage: byLineage,
|
||||
by_effect_class: byEffect,
|
||||
sources: [...projections].sort((a, b) => compare(identity(a), identity(b)))
|
||||
.map(item => ({
|
||||
identity_hash: hashValue([item.run_id, item.capsule_id]),
|
||||
...Object.fromEntries(SOURCE_FIELDS.map(key => [key, item[key]])),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Group up to 100 selected snapshots of one task family by harness_version.
|
||||
* Duplicate identities count once only if the verified projections match.
|
||||
* Different checkpoints of the same identity are ambiguous and refused.
|
||||
* Does not read saved projections, copy payloads, write files or invoke tools.
|
||||
*/
|
||||
function groupCapsules(dirs) {
|
||||
if (!Array.isArray(dirs) || dirs.length < 1 || dirs.length > MAX_INPUTS
|
||||
|| !Array.from(dirs).every(dir => typeof dir === 'string' && dir.trim() && !dir.includes('\0'))) {
|
||||
throw new RetrospectiveError('retrospective.invalid_inputs', `supply 1 to ${MAX_INPUTS} capsule directory paths`);
|
||||
}
|
||||
const projections = dirs.map(readProjection);
|
||||
const family = projections[0].task_family;
|
||||
const unique = new Map();
|
||||
for (const [index, projection] of projections.entries()) {
|
||||
if (projection.task_family !== family) {
|
||||
throw new RetrospectiveError('retrospective.mixed_task_families', 'all capsules must have the same task family', index);
|
||||
}
|
||||
const key = identity(projection);
|
||||
const previous = unique.get(key);
|
||||
if (previous && previous.projection_hash !== projection.projection_hash) {
|
||||
throw new RetrospectiveError('retrospective.conflicting_identity', 'conflicting snapshots share a capsule identity', index);
|
||||
}
|
||||
unique.set(key, projection);
|
||||
}
|
||||
const byHarness = new Map();
|
||||
for (const projection of unique.values()) {
|
||||
const version = projection.harness_version;
|
||||
byHarness.set(version, [...(byHarness.get(version) || []), projection]);
|
||||
}
|
||||
const report = {
|
||||
schema: 'capsule-retrospective/v1',
|
||||
report_only: true,
|
||||
task_family: family,
|
||||
input_count: dirs.length,
|
||||
capsule_count: unique.size,
|
||||
duplicate_count: dirs.length - unique.size,
|
||||
groups: [...byHarness].sort(([a], [b]) => compare(a, b))
|
||||
.map(([version, items]) => summarizeGroup(version, items)),
|
||||
};
|
||||
return { ...report, report_hash: hashValue(report) };
|
||||
}
|
||||
|
||||
module.exports = { groupCapsules, MAX_INPUTS, RetrospectiveError };
|
||||
Reference in New Issue
Block a user