feat(HSP-067): exit-manifest — portabel export + notice-krav + icke-exporterbart explisit #95
@@ -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));
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user