# simvsm-import

> Module created via `npm run new:module simvsm-import` (structure only —
> content written by hand for the real shape, ADR 019 golden path).

## Owner

Wertstrom

## Purpose

Wertstrom P6 (KAR-878/KAR-986, execution-prompt §14 "Import and
Compatibility Layer", Capability-Matrix A16): an **optional** adapter that
imports SimVSM tool exports (`.json`, paired `.svg`) into SupplierPulse's
existing Wertstrom data model. SupplierPulse does not depend on this for
normal use (§14 preamble) — it exists because Kais' consulting practice has
real SimVSM holdings (the corpus this was built and validated against).

Two-stage, persist-nothing-until-confirmed flow (§14.1/§14.2), behind a new
flag `wertstromSimvsmImport` (`false` in all 3 profiles — see
`config/profiles/`, gate in `lib/api/feature-gates.ts`
`wertstromSimvsmImportGate`, same shape as `wertstromUxV2Gate`).

## Architecture decisions

**Zero schema changes.** Every SimVSM field this module maps lands on a
`VsmNode`/`VsmConnection` field that already exists (P2's `provenance`/
`inventoryKind`/`ConnectionKind`, P4's `availabilityPct`/`mttrMin`/
`transportFrequency`) or on `value_stream_maps.layout` (jsonb, the same
"additive bucket, no migration" precedent `layout.viewport`/`layout.
shiftModel` already established). No `docs/adr` amendment, no
`supabase/migrations/*.sql` file was added — the brief's own resolution rule
for an unresolvable schema question ("ohne DDL lösen, JSONB-Ablage, KEINE
Migration anlegen") applied cleanly because the P1–P5 phases already built
everything this needed.

**No SECURITY DEFINER RPC.** QVS's `create_value_stream_from_qaf` needed one
because a NEW table (`value_stream_imports`) had to be written atomically
alongside `value_stream_maps`. This module writes only to the pre-existing
`value_stream_maps` table, so persistence is N sequential plain
`.insert()` calls (`internal/persist.ts`) — the same "own transaction per
row, failure reported per row, never hidden" pattern
`createValueStreamsForVariants` already established for "je Variante ein
Wertstrom". See "Bekannte Grenze: Idempotenz ohne DDL" below for the
trade-off this implies.

**Confirm re-parses from re-uploaded bytes, never trusts a client node
array.** There is no server-side store for the raw SimVSM file between
preview and confirm (no Storage-bucket wiring in P6 scope) — the confirm
route (`app/api/wertstrom/import/confirm`) receives the SAME file again and
maps it itself. This is deliberately the SAME "never trust the client
wholesale" posture `lib/qaf-value-stream/internal/creation.ts` documents for
its own confirm path, just with the file bytes standing in for QVS's
DB-backed source rows. A `previewSignature` staleness check
(`signature.ts`) rejects a confirm whose re-uploaded bytes don't match what
the user actually previewed. Since C15 (Review-Fix, adversarial review PR
#360), the signature also mixes in `SIMVSM_ADAPTER_VERSION` (derived from
`PARSER_VERSION`+`MAPPING_VERSION`) — so the SAME staleness check now also
catches a parser/mapping-registry deploy landing between preview and
confirm, not only byte-content drift, with no extra field on the wire.
Declared consequence: this signature is also the persisted
`layout.simvsmSource.sourceSignature` duplicate-detection key
(`duplicates.ts`) — after an adapter version bump, a previously-imported
file re-uploaded unchanged is no longer recognized as a duplicate by that
(explicitly best-effort) hint. Accepted, same class of imperfection the
duplicate hint already tolerates elsewhere (see its own "degraded" flag).

**SVG is metadata-only, never parsed for values.** Only `File.name` is ever
read for an `.svg` upload (never its bytes) — used solely for
`internal/families.ts`'s naming-convention pairing (§14.1 point 2 "see which
files belong together"). This means SVG upload size is a non-issue for
SERVER MEMORY (no cost beyond the filename string) — but it is NOT a
non-issue for the HTTP request body: the client still uploads the full SVG
bytes in the same multipart request as the JSON file(s) (Review-Fix, C7,
adversarial review PR #360 — an earlier version of this README overclaimed
"non-issue" without that distinction). `internal/upload-limits.ts` therefore
enforces its own (smaller) per-file SVG byte limit, same as JSON — see
"Größenlimit / Body-Limit" below.

**"Familie" = one JSON file's `name` + `alternatives[]` + its paired SVGs —
not a cross-file merge.** Real-corpus inspection (local-only, confidential)
showed same-named snapshot files hold genuinely DIFFERENT alternative sets
between versions; auto-merging multiple JSON files into one family risked
silently dropping or conflating real data. Cross-file relatedness is
therefore only ever a soft, non-binding hint
(`PreviewFamily.relatedFileNames`, filename-stem similarity) — the user
still explicitly picks which file(s) and which of their streams to import.

**Positions are always recomputed via the existing Auto-Layout, never taken
from SimVSM's own `loc`.** `internal/streams.ts` calls
`components/wertstrom/vsm-auto-layout.ts`'s `computeAutoLayout` (P3) on the
mapped node/connection set — reuse, not reinvention, and it sidesteps
SimVSM's canvas coordinates being able to go negative/unbounded while the
editor's world is not. Reported as a "visual layout difference" (§14.4 has
its own bucket for exactly this), never silently papered over with an ad hoc
coordinate transform. `SimVsmParsedNode.loc` is parsed and kept only for
potential future diagnostics — nothing in this module reads it for
placement today.

## Real-corpus deviations from the execution-prompt (declared per brief)

- **`isMain` is never `true` in the real corpus** (0 of 73 alternatives
  across all 25 files, including single-alternative files — measured
  locally, not committed). §14.3/Capability-Matrix A9 treat it as the
  natural "this is the Ist-Zustand" signal; in practice it carries no
  information. `resolveCurrentStreamIndex` still reads `isMain` first
  (forward-compatible) but the LOAD-BEARING rule is the fallback: the first
  alternative in array order is `scenario_kind: 'current'`, every other
  alternative in the same file is `'alternative'`. Surfaced in the preview,
  not hidden — P6 has no manual "make this the current one" override control
  (kept out of §14.1's deliberately simple flow); documented gap for P7/P8.
- **Largest real file is ~13.4 MiB, not "~6 MB"** (the brief's own
  assumption). `KORPUS-REPORT.md` itself already says "Aumovia (1, 14 MB —
  größte Datei)" — the brief's "größte Korpus-Datei ~6 MB" undersells its own
  source document. See "Größenlimit" below for the consequence.
- **A dedicated PPS/"production control" NodeType, and a note/Kaizen
  NodeType, do not exist in the current data model** (`lib/vsm-types.ts`'s
  `NodeType` union has 7 fixed members, no 8th "annotation" or "control"
  kind) — extending it is an editor/rendering change (new `vsm-config.ts`
  colors, new canvas shape) Capability-Matrix explicitly assigns to later
  phases (A18 Kaizen-Marker is P8). `productionControl` is therefore
  approximated onto `process` with `isValueAdded: false` (documented
  "transformed" entity, §14.4) rather than dropped — dropping it would
  orphan the informationFlow edges that very often terminate there, and
  KORPUS-REPORT.md itself calls informationFlow "Pflicht" (370 occurrences).
  `noteVSM`/`noteAnnotation` (and the other pure-annotation classes) have no
  reasonable target at all and are reported as **unsupported**, not
  imported — §14.3's own example mapping ("noteVSM to Kaizen opportunity")
  is therefore RECOGNIZED + COUNTED but not structurally realized in P6; see
  `internal/mapping-registry.ts` file header for the full class-by-class
  reasoning.

## Größenlimit / Body-Limit (Baustein 1, explicit per brief)

The pure parser (`internal/parser.ts`) has only a generous sanity ceiling
(`PARSER_MAX_JSON_BYTES`, 64 MB) — it is exercised against the FULL real
corpus (`internal/__tests__/*.real-corpus.test.ts`, including the ~13.4 MiB
file) regardless of any HTTP-layer limit.

The HTTP route (`app/api/wertstrom/import/preview`) is a Next.js App Router
Route Handler with **no Next.js-level body-size config for Route Handlers**
(unlike Server Actions' `serverActions.bodySizeLimit`, which does not apply
here). The real constraint is the deployment platform: on Vercel, a Node.js
Serverless Function request body is capped at **4.5 MB** — a hard platform
limit, not something `next.config.mjs` can raise. This repo deploys to
Vercel today (`CLAUDE.md`).

**Consequence, declared as a judgment call:** the route enforces its own
limits, ALL centralized in `internal/upload-limits.ts` (Review-Fix C7/C13/
C14/C19, adversarial review PR #360 — previously JSON-only, checked size
before type, and both the preview and confirm route each had their own ad
hoc copy):

- `JSON_FILE_MAX_BYTES` — 4 MB per `.json` file (unchanged from the original
  limit), comfortably under the 4.5 MB platform ceiling.
- `SVG_FILE_MAX_BYTES` — 2 MB per `.svg` file (SVG bytes are never read as
  content, but they DO count against the request body — see "SVG is
  metadata-only" above).
- `TOTAL_REQUEST_MAX_BYTES` — 4.2 MB summed across every file in one
  request, checked BEFORE any per-file processing — headroom above a single
  near-limit JSON file, still safely under Vercel's ~4.5 MB hard cap once
  multipart framing/field overhead is accounted for.
- Per file, the EXTENSION/type check always runs before the size check —
  an unsupported extension (e.g. an accidentally-selected `.txt`) is
  reported as exactly that, never mislabeled "zu groß".

Both `app/api/wertstrom/import/preview/route.ts` and `.../confirm/route.ts`
call the SAME `classifyUploadFile`/`checkTotalUploadSize` functions — not a
copy-pasted re-implementation, so the two routes' limits can never drift
apart again. Any rejection is a clear, honest, per-file German error —
never a silent truncation, never a crash. Given the real corpus's measured
size distribution, this means the single largest real file (~13.4 MiB)
**cannot be imported through this route on the current Vercel deployment**
— a real, disclosed scope boundary, not an oversight. A larger-file path
(chunked upload, or a Storage-bucket detour) is out of P6 scope and listed
in TODO.md for P7/P8. The Docker/Azure Container Apps target
(`DEPLOYMENT.md`) does not share Vercel's specific platform cap, but this
module does not special-case runtime target — the same conservative limits
apply everywhere for one predictable behavior.

## Bekannte Grenze: Idempotenz ohne DDL

`internal/persist.ts`'s idempotency check (find-then-insert) has a TOCTOU
race: two concurrent confirms of the exact same (file, stream selection,
project) could both pass the "not found" check before either writes,
producing two data sets. QVS's `value_stream_imports_preview_token_idx`
(a partial UNIQUE index) closes this exact race — unavailable here without a
migration. Accepted as a documented, low-probability risk (a user
double-clicking a confirm button in two tabs), not silently ignored — see
TODO.md.

## Confidentiality

Every class/field name in this module (`singleProcess`, `fifoStore`,
`ProductTable`, `PlannedCycleTime`, …) is SimVSM's own generic tool
vocabulary — also listed in §14.3's own spec examples — never a real
supplier/plant/product name or a corpus number. The real corpus itself lives
only at the path named in `SIMVSM_CORPUS_DIR` (an env var, never hardcoded
in this repo) and is never read by anything except the env-gated real-corpus
tests, which assert structure/counts only — never a corpus literal.

## Public API

The public surface is the named exports of `index.ts`. Anything in
`internal/` is private and may change without notice.

- **`parseSimVsmJsonText(rawText, fileName)`** — raw bytes → structural
  parse. Never throws.
- **`NODE_MAPPING_REGISTRY` / `LINK_MAPPING_REGISTRY` / `MAPPING_VERSION`**
  (`'simvsm-1'`) — the explicit, corpus-validated §14.3 mapping table.
- **`mapSimVsmNode` / `mapSimVsmLink`** — per-element field mapping onto
  `VsmNode`/`VsmConnection`.
- **`mapStream` / `resolveCurrentStreamIndex`** — one `alternatives[]` entry
  → a full node/connection set + P2 scenario role.
- **`pairSvgFiles` / `groupRelatedFileNames`** — Familie/SVG naming
  convention (Baustein 1).
- **`computeSourceSignature` / `computeConfirmationSignature`** — content-hash
  fingerprints for duplicate-hint + idempotency.
- **`buildImportReport`** — §14.4 report, shared by preview (forecast) and
  confirm (actual result) so the two can never drift apart.
- **`buildSimvsmImportPreview`** — pure, DB-free preview builder.
- **`findSimvsmDuplicatesBySignature` / `findSimvsmImportByConfirmationSignature`
  / `persistSimvsmImport`** — DB-backed (RLS-scoped Supabase client only,
  never `lib/supabase/admin.ts`).

## Dependencies

- `@/lib/vsm-types` — `VsmNode`/`VsmConnection`/`ScenarioKind`/`InventoryKind`/`ConnectionKind` (read-only).
- `@/lib/api/schemas` — `VsmNodeSchema`/`VsmConnectionSchema`, used as a defensive self-check before `persist.ts` ever writes (belt-and-suspenders on top of this module's own compile-time types).
- `@/components/wertstrom/vsm-auto-layout` — `computeAutoLayout` (pure geometry, no React/DOM) — same cross-module reuse precedent `lib/qaf-value-stream` already established with `vsm-geometry`'s `NODE_W`.
- `@/lib/logger` — structured logging (no-console discipline).
- `@supabase/supabase-js` — `SupabaseClient` type only; the concrete client is always injected by the caller (`app/api/wertstrom/import/*`), never imported from `@/lib/supabase/admin.ts`.

## Tests

Co-located under `internal/__tests__/`. Run via `npx vitest run lib/simvsm-import`.

Committed fixtures are 100% synthetic — invented plant/process names
("Werk Nord" etc.), invented numbers, hand-built to match the real SimVSM
JSON *shape* (verified against the real corpus's structure, never its
content) — same "Fixture-Daten-Regel" `app/wertstrom/[id]/__tests__/page.
test.ts` (P5) already established.

`internal/__tests__/*.real-corpus.test.ts` are env-gated
(`describe.skipIf(!process.env.SIMVSM_CORPUS_DIR)`, run locally by pointing
`SIMVSM_CORPUS_DIR` at a local copy of the confidential test corpus, always
skip in CI) — mandatory P6 deliverable per the brief, same discipline as `lib/qaf-value-stream`'s
`*.real-files.test.ts`. Every assertion is structural (parse-without-throw,
class coverage, family/stream counts derived from `_inventar.json`-shaped
data read from the env-provided directory at run time) — no corpus filename,
supplier name, or number is ever a committed literal.

## Conventions (ADR 019)

- No deep imports into other modules' `internal/` directories.
- This module never imports from `lib/customers/**` (ADR 010 / ADR 015).
- Customer-specific behaviour goes via the active composition profile (ADR 013).
