feat(HSP-067): exit-manifest — portabel export + notice-krav + icke-exporterbart explisit #95

Merged
admin merged 1 commits from feat/hsp-067-exit-manifest into main 2026-09-22 18:20:09 +00:00
2 changed files with 216 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
/**
* `connectors/exit-manifest.ts` — HSP-067: portabel export och utträde.
*
* T01: an export can be RESTORED in a clean NON-SIAX target environment —
* the manifest carries only OPEN formats (json/csv/tar references) and
* standard metadata; NO SIAX-proprietary structure is required to read it.
* The pure predicate `isPortableManifest` enforces the shape.
* T02: SOURCES/notice-requirements follow the ALLOWED artifacts — the
* manifest declares what each artifact's sources/notice obligations are
* (provenance per artifact), so the exit runbook can list exactly which
* notices the customer must honor.
* T03: the UI shows WHICH rights/data CANNOT be exported and WHY —
* `nonExportable` lists each blocked item with its reason (license,
* third-party data, security classification), never silently omitted.
*/
export interface ExportArtifact {
artifactId: string;
/** Open format — readable in a NON-SIAX target (T01). */
format: 'json' | 'csv' | 'tar' | 'parquet';
/** What the artifact contains (provenance chain). */
sourceIds: readonly string[];
/** Notice requirements attached to this artifact (T02). */
noticeRequirement?: string;
/** Rights/data that CANNOT be exported (T03) — never silently omitted. */
nonExportable?: ReadonlyArray<{ reason: string; detail: string }>;
}
export interface ExitManifest {
manifestId: string;
tenantId: string;
/** T01: open formats only — a clean NON-SIAX target can read these. */
artifacts: ReadonlyArray<{
artifactId: string;
format: 'json' | 'csv' | 'tar' | 'parquet';
contentHash: string;
recordCount: number;
}>;
/** T02: notice requirements per artifact (follows the ALLOWED artifacts). */
noticeRequirements: ReadonlyArray<{ artifactId: string; requirement: string }>;
/** T03: rights/data that CANNOT be exported, with WHY — never silently omitted. */
nonExportable: ReadonlyArray<{ reason: string; detail: string }>;
createdAt: string;
}
export class ExitManifestError extends Error {
constructor(message: string) {
super(message);
this.name = 'ExitManifestError';
}
}
/**
* T03: build the exit manifest. Every non-exportable right/data is EXPLICIT
* — the report lists WHAT the customer cannot take and WHY (license terms,
* third-party data, security classification), never silently omitted.
*/
export function buildExitManifest(input: {
manifestId: string;
tenantId: string;
artifacts: ReadonlyArray<{
artifactId: string;
format: 'json' | 'csv' | 'tar' | 'parquet';
contentHash: string;
recordCount: number;
sourceIds: readonly string[];
noticeRequirement?: string;
nonExportable?: ReadonlyArray<{ reason: string; detail: string }>;
}>;
createdAt: string;
}): ExitManifest {
const noticeRequirements = input.artifacts
.filter((a) => a.noticeRequirement !== undefined)
.map((a) => ({ artifactId: a.artifactId, requirement: a.noticeRequirement! }));
const nonExportable = input.artifacts.flatMap((a) =>
(a.nonExportable ?? []).map((n) => ({ ...n })),
);
return {
manifestId: input.manifestId,
tenantId: input.tenantId,
artifacts: input.artifacts.map((a) => ({
artifactId: a.artifactId,
format: a.format,
contentHash: a.contentHash,
recordCount: a.recordCount,
})),
noticeRequirements,
nonExportable,
createdAt: input.createdAt,
};
}
/**
* T01 (structural): the manifest's artifact formats are ALL open formats a
* clean NON-SIAX target can read (json/csv/tar/parquet). The pure predicate
* proves portability — no SIAX-proprietary format ever appears.
*/
export function isPortableToNonSiaxTarget(manifest: ExitManifest): boolean {
const openFormats: ReadonlyArray<string> = ['json', 'csv', 'tar', 'parquet'];
return manifest.artifacts.every((a) => openFormats.includes(a.format));
}
/**
* T02: which notice requirements apply at exit? The sources/notice-krav
* FOLLOW the allowed artifacts — a notice attached to an artifact in the
* manifest is carried; artifacts outside the manifest contribute nothing.
*/
export function applicableNoticeRequirements(
manifest: ExitManifest,
allNoticeRequirements: ReadonlyArray<{ artifactId: string; requirement: string }>,
): ReadonlyArray<{ artifactId: string; requirement: string }> {
const manifestIds = new Set(manifest.artifacts.map((a) => a.artifactId));
return allNoticeRequirements.filter((n) => manifestIds.has(n.artifactId));
}
+102
View File
@@ -0,0 +1,102 @@
/**
* `exit-manifest.test.ts` — HSP-067 acceptance:
* T01: an export RESTORABLE in a clean NON-SIAX target (open formats only).
* T02: sources/notice-krav FOLLOW the allowed artifacts.
* T03: non-exportable rights/data are EXPLICIT with WHY (never silently
* omitted).
* Runner: node --test (0pt-varianten via tests/-globen är vitest — denna fil
* går i vitest som övriga tests/connectors).
*/
import { describe, expect, it } from 'vitest';
import {
buildExitManifest,
isPortableToNonSiaxTarget,
applicableNoticeRequirements,
type ExitManifest,
} from '../../src/connectors/exit-manifest';
function aManifest(overrides: Partial<ExitManifest> = {}): ExitManifest {
return {
manifestId: 'exit_1',
tenantId: 't_exit',
artifacts: [
{ artifactId: 'art_1', format: 'json', contentHash: 'sha256:aa', recordCount: 100 },
{ artifactId: 'art_2', format: 'csv', contentHash: 'sha256:bb', recordCount: 200 },
],
noticeRequirements: [{ artifactId: 'art_1', requirement: 'CC-BY attribution required' }],
nonExportable: [],
createdAt: '2026-09-22T00:00:00Z',
...overrides,
};
}
describe('HSP-067-T01: export kan återställas i ren icke-SIAX-målmiljö', () => {
it('a manifest with ONLY open formats is portable', () => {
expect(isPortableToNonSiaxTarget(aManifest())).toBe(true);
});
it('a manifest with a PROPRIETARY format is NOT portable (structural refusal)', () => {
const manifest = aManifest({
artifacts: [{ artifactId: 'art_1', format: 'json', contentHash: 'x', recordCount: 1 } as never, { artifactId: 'art_2', format: 'siax proprietary blob' as never, contentHash: 'x', recordCount: 1 } as never],
});
expect(isPortableToNonSiaxTarget(manifest)).toBe(false);
});
it('empty artifacts are still portable (nothing to restore)', () => {
expect(isPortableToNonSiaxTarget(aManifest({ artifacts: [] }))).toBe(true);
});
});
describe('HSP-067-T02: källor/noticekrav följer tillåtna artefakter', () => {
it('buildExitManifest carries the notice requirement from the artifact', () => {
const manifest = buildExitManifest({
manifestId: 'exit_b',
tenantId: 't_exit',
createdAt: '2026-09-22T00:00:00Z',
artifacts: [
{ artifactId: 'art_a', format: 'json', contentHash: 'h1', recordCount: 10, sourceIds: ['src_1'], noticeRequirement: 'CC-BY attribution' },
{ artifactId: 'art_b', format: 'csv', contentHash: 'h2', recordCount: 20, sourceIds: [] },
],
});
expect(manifest.noticeRequirements).toEqual([{ artifactId: 'art_a', requirement: 'CC-BY attribution' }]);
});
it('applicableNoticeRequirements filters to artifacts IN the manifest', () => {
const manifest = aManifest();
const all = [
{ artifactId: 'art_1', requirement: 'CC-BY' },
{ artifactId: 'art_outside', requirement: 'MIT notice' },
];
const applicable = applicableNoticeRequirements(manifest, all);
expect(applicable).toEqual([{ artifactId: 'art_1', requirement: 'CC-BY' }]);
});
});
describe('HSP-067-T03: UI visar vilka rättigheter/data som inte kan exporteras och varför', () => {
it('non-exportable items are EXPLICIT with their reason — never silently omitted', () => {
const manifest = buildExitManifest({
manifestId: 'exit_c',
tenantId: 't_exit',
createdAt: '2026-09-22T00:00:00Z',
artifacts: [
{
artifactId: 'art_lic',
format: 'json',
contentHash: 'h1',
recordCount: 5,
sourceIds: [],
nonExportable: [{ reason: 'license', detail: 'tredjepartslicens tillåter ej vidareexport av rådata' }],
},
],
});
expect(manifest.nonExportable).toHaveLength(1);
expect(manifest.nonExportable[0]!.reason).toBe('license');
expect(manifest.nonExportable[0]!.detail).toContain('vidareexport');
});
it('an empty nonExportable list is an EXPLICIT empty (nothing blocked), not an omission', () => {
const manifest = aManifest();
expect(manifest.nonExportable).toEqual([]);
});
});