@mark2form/contracts
v0.13.0
Published
De enige contractbron van het Mark2Form-platform: zod-schema's en types die apps, packages en de twee websites delen.
Readme
@mark2form/contracts
De contractbron van het Mark2Form-platform: de enige plek waar de types,
zod-schema's, protocollen, claimsets, event-namen, staffelvormen en gedeelde
configsecties staan die apps, packages en de twee websites met elkaar delen.
Nul runtime-deps behalve zod, en geen import uit een ander package — de
afhankelijkheidsrichting van het platform loopt hiernaartoe, nooit hiervandaan.
Structuurplan §3 principe 3: "Types, schema's, event-namen, protocollen en prijzen staan één keer, in
packages/contractsofpackages/catalog; wie ze nodig heeft importeert ze. Een kopie is een bug."
Wat hier wel en niet hoort
Wel: vorm (schema's + types), de defaults en clamps die BEIDE kanten van een grens moeten delen, en pure lezers daarop.
Niet: app-gedrag, I/O, env-lezing, database, en niets dat een apps/-pad
importeert. Een functie die een EditorSnapshot leest of sessionStorage
aanraakt hoort in de app.
Deze grens is niet cosmetisch. Elke drift die het structuurplan in Bijlage A
noemt ontstond doordat twee kanten bijna hetzelfde deden: de embed-JWT-claim
po bestond alleen in het portaal, STUDIO_MAX_PRESETS stond op 6 en op 8, en
VISION_DAILY_BUDGET_DEFAULT stond twee keer met aan beide kanten een
commentaarregel die de ander opdroeg mee te veranderen. Niets faalde.
product-module en events
product-module is de ProductModule-interface uit §8.5: wat het portaal
en de runtime van een product moeten weten zónder de engine te laden — naam,
slug, configfamilie, modi, uitvoerformaten, staffels, btw-regel, accent, tabs
en het tenant-configschema. Fase 2 vult alleen beschrijving (daarvoor staat
ProductModuleFase2 klaar); defaults, render, pipeline en quality
zijn fase 5 en dragen tot die tijd unknown. De consumenten zijn
packages/products/* (die de module leveren) en apps/portal en
apps/runtime (die alleen de vorm kennen).
De vormen Vertaling, StaffelTrap, ModusBeschrijving en
UitvoerformaatBeschrijving lijken op Tekst, Staffel, Modus en
Uitvoerformaat uit @mark2form/catalog en zijn dat met opzet niet: dit
package importeert uit geen enkel ander package en catalog heeft nul
dependencies, dus die twee kunnen geen gedeeld type hebben. De uitweg is
structureel typen — hier staat de vorm met string waar catalog een eigen unie
heeft (Taal, ModusId, UitvoerformaatId), zodat catalog-data er zonder
omweg in past. Het bewijs dat ze niet uit elkaar lopen wordt geleverd door
packages/products/neon, het enige package dat van allebei afhangt, met één
satisfies ProductModuleBeschrijving. Verbreed de types hier dus nooit naar
Record<Taal, string> of ModusId: dat zou de catalog-unies overtypen, en
overtypen is de bug die §3 principe 3 bedoelt.
events is de event-enum: EVENT_TYPES in de volgorde van de
CHECK-constraint events_event_type_check, plus EventType, isEventType,
EventTypeSchema en de groepering (EVENT_GROEPEN, groepVanEvent) die het
portaal telt als impressie, interactie, upload, trace of bestelling. Daarnaast
staat hier de publieke zeef: PUBLIEKE_EVENT_TYPES (wat POST
/api/event van een embed accepteert) naast SERVER_ONLY_EVENT_TYPES (de vier
types die omzet- en conversiecijfers aansturen: payment_completed,
invoice_created, demo_requested, demo_activated). Beide lijsten zijn met
de hand geschreven en de test eist dat hun unie exact EVENT_TYPES is — een
nieuw type dat niemand classificeert laat de suite falen in plaats van
stilletjes publiek postbaar te worden. De
constraint is leidend: whitelist en TS-union kunnen ernaast zitten zonder dat
er iets kapotgaat, de database niet. Drie types zitten opvallend NIET in een
groep — trace_failure (wel een trace-gebeurtenis, geen geslaagde omzetting),
export (een eigen funnel-stap, zou naast submission dubbeltellen) en
config_saved (een handeling van de klant, geen bezoekersmeting). De redenen
staan per type in de bron.
endpoints
endpoints is de machine-leesbare tabel van §5.3: alle 25 endpoints van
/api/v1 met hun id, pad, methoden, klasse (public-browser /
server-to-server / proxied), auth, het OUDE pad dat als alias mee moet
en een toelichting. zetWildcardCors(klasse) is de enige plek waar de
CORS-regel uit die klasse volgt — §5.3 zegt dat negen huidige routes
access-control-allow-origin: * zetten en dat alleen de public-browser-rijen
hem houden; hier is dat data in plaats van negen keer hetzelfde met de hand.
Zes van die negen houden hun wildcard, drie raken hem kwijt
(embed-session-refresh, embed-meta, designs-create), en de test bevriest
die uitkomst met een handgeschreven verwachtingstabel. aliasPermanent is
precies één keer waar: embed-legacy-loader, want GET /api/embed/<slug>.js
staat in klant-HTML die wij niet kunnen wijzigen.
Er staat bewust géén status in. De tijdelijke kopie in apps/portal
draagt per rij ook de bouwstand (gebouwd / wacht-op-l1 / fase-3); dat is
de stand van één deployment op één moment en geen afspraak tussen twee kanten.
Een contract dat zijn eigen bouwstand meepubliceert dwingt een versiebump af
zonder dat er iets aan het contract verandert, en vertelt een consument iets
dat voor hém niet waar hoeft te zijn. De app houdt die kolom in een eigen
overlay. Om dezelfde reden ontbreekt routebestand() (:id → [id]): dat is
de mapnaamconventie van Next, geen eigenschap van het endpoint.
Het design-id-headerbesluit hoort hier ook: DESIGN_ID_HEADER is
x-m2f-design-id, want §5.2 hernoemt het protocol naar m2f:* en een merknaam
in een header is een merknaam in code. DESIGN_ID_HEADER_LEGACY is
x-upgraded-design-id — wat de runtime vandaag stuurt. Het portaal accepteert
beide tot de runtime in zijn eigen PR overstapt; zijn allebei gezet, dan wint de
nieuwe. Wie de nieuwe naam toelaat noemt hem ook in
access-control-allow-headers, anders faalt het cart-relais stil op een
preflight.
embed-protocol
embed-protocol is het embed-protocol v1 uit §5.2 (issue #49): de vijf
berichttypen van de handshake als { m2f, legacy }-paren in EMBED_BERICHTEN
(m2f:ready/upgraded:ready t/m m2f:request-close/upgraded:request_close),
het legacy-containervoorvoegsel upgraded-, het custom element
<mark2form-configurator>, data-m2f met alias data-upg, de hoogteklem
200–5000 px, de init-timeout van 1500 ms en de headers van het cart-relais
(x-m2f-signature/x-upgraded-signature, x-m2f-event/x-upgraded-event,
eventwaarde design.submitted, user-agent UpgradedPortalRelay/1.0).
De upgraded:*-namen zijn hier geen merkstring maar protocol: ze staan in
klant-HTML en tenant-webhook-verifiers die wij niet kunnen wijzigen, en §5.2
houdt ze als alias gedurende de hele v1-levensduur — de nieuwe naam komt
ernaast, nooit in de plaats. Elke legacy-waarde is byte-gelijk nagelezen in de
code die hem vandaag spreekt; de bronnen staan per constante in het docblok, en
de test bevriest ze letterlijk. Het design-id in dezelfde relay-headers
(DESIGN_ID_HEADER(_LEGACY)) stond al in endpoints en blijft daar.
tenant-config-schema en branding
tenant-config-schema is het volledige zod-model van software_configs.config,
met per veld een .meta() die zegt wat het portaal moet tonen en waar de waarde
landt. Drie afwijkingen tussen dit schema en wat er LIVE staat zijn in 0.5.0
rechtgetrokken (issue #38, sectie A):
| Veld | Was | Is |
|---|---|---|
| general.tubeDiametersMm | z.array(z.number()) | z.array(z.string()) — de opslagvorm (string_list, migratie 0011/0012). tubeDiametersMmAlsGetallen() is de ENE omzetting naar millimeters |
| branding | {brandLogoUrl, primaryColor, secondaryColor} | de vorm die gelezen wordt (hieronder); de camelCase-sleutels blijven als leeslocatie met portalField: false |
| production.profile | ontbrak | staat erin, letterlijk zoals migratie 0037 hem definieert — mergeConfigSave kan het pad nu schrijven |
tenantConfigVelden() is de introspectie: per veld pad, sectie, type,
zodType, het schema, de uitgepelde kern en de meta. unwrapVeld,
vindVeldMeta, veldVorm, enumOpties en afgeleidVeldType staan er los
naast. Ze stonden tot 0.5.0 als private helpers in
apps/runtime/scripts/generate-config-schema.ts én in het portaal: twee kopieën
van dezelfde aannames over zods interne def. De lezer laat bewust niets weg
(portalField: false en normalisator-secties komen mee) en daalt niet af in
arrays — die rijen zijn data. Wie een rij-editor bouwt leest NeonColorSchema
en RoomSchema, die sinds 0.5.0 per subveld een label dragen plus de
rij-typen hex en image.
Veldlabels zijn Nederlands en blijven string. labelVertaling,
helpVertaling en (op een optie) labelVertaling staan ernaast, met
Vertaling uit product-module; veldLabel, veldHelp en optieLabel lezen
ze en zeggen erbij in wélke taal de tekst staat en of het de terugval is. Dat
laatste is wat een scherm in zijn lang-attribuut hoort te zetten. Er is nog
niets vertaald, en dat hoor je te zien.
branding is de branding van één grant, in de vorm die GELEZEN wordt —
{ logo_url, primary_color, brand_name, offer_email, powered_by }. Die vorm
komt uit extractBranding (config-model), readBrandingFromGrant (runtime) en
de mailsjablonen; het portaalscherm schrijft hem al, want anders ziet de
bezoeker het logo niet. primary_color is bewust NIET nullable: beide lezers
vallen terug op BRANDING_DEFAULT_PRIMARY_COLOR, dus een lezer heeft altijd
een kleur. powered_by is nieuw en staat standaard AAN — de vermelding staat
er vandaag, dus een ontbrekende sleutel moet true betekenen. De opgeslagen
vorm is BrandingSchema.partial() en dus geen tweede sleutellijst.
DesignOrigin / ProductKind en de leesalias
Eigenaarsbesluit Pascal, 21-9-2026 (issue #74):
Neon Studio de CATEGORIE: hier beheer je je instellingen
├── Neon Logo product, eigen staffel
└── Neon Tekst product, eigen staffelDesignOrigin (en zijn alias ProductKind) draagt daarom 'logo' | 'tekst'
in plaats van 'logo' | 'studio'. Dat zijn nu dezelfde twee waarden als
OutboxModus, zodat de tarief-afbeelding tussen runtime en factuur kan
verdwijnen; één test legt de lijsten naast elkaar.
'studio' staat nog in bewaarde gegevens — revisies, autosaves en elke
#design=-deel-link die ooit de deur uit ging. Daarvoor is er één lezer, en
die gaat maar één kant op:
import { leesDesignOrigin, designOriginOfLogo } from "@mark2form/contracts";
leesDesignOrigin("studio"); // "tekst"
leesDesignOrigin("tekst"); // "tekst"
leesDesignOrigin("van alles"); // null
designOriginOfLogo(undefined); // "logo" — ontbreekt → logo (G1)Er bestaat géén tekst → studio, en 'studio' zit niet in DesignOrigin:
een schrijver krijgt de waarde genormaliseerd of hij compileert niet. Zet de
lezer op de PARSE-grens waar bewaarde gegevens binnenkomen — decodeShareUrl,
loadDesign, de snapshot-lezer van de editor, leesEmbedStand — en nergens
anders. Niet bij het herafleiden van een bestaande design_revision: die
waarde zit in de gehashte inhoud, dus normaliseren levert daar een andere
contentHash dan de opgeslagene. Of de alias ooit weg kan staat bij
DESIGN_ORIGIN_LEESALIASSEN in neon-domain.ts.
outbox en usage-download
outbox zijn de domein-events van §5.4: de zeven typen
(download.completed, order.created, order.status_changed,
proof.decided, payment.completed, review.approved,
subscription.changed), elk met zijn eigen payload-schema, samen in
OutboxEventSchema — een discriminatedUnion op type, zodat een payload
nooit onder het verkeerde type kan hangen. Alles wat geld raakt of waar een
ander systeem op moet reageren gaat als rij de tabel outbox in
(id, type, key unique, payload, created_at, processed_at, attempts); het
PORTAAL schrijft ze, een worker-taak verwerkt ze (grootboek, mail, Stripe,
webhooks naar de maker). Omdat key uniek is, levert dezelfde gebeurtenis
twee keer melden één rij en dus één factuurregel — precies waarom §5.4 een
afzender opdraagt bij géén 2xx te blijven proberen en het event nooit te laten
vallen.
De idempotentiesleutel van download.completed staat hier als invoerstring
en niet als hash: downloadIdempotencyInvoer(payload, maand) geeft
bedrijf_id|modus|design_revision_hash|maand, en billing en de runtime halen
daar zelf een SHA-256 overheen met node:crypto. Dat is geen halve
implementatie maar de grens op de goede plek — dit package heeft alleen zod
als dependency (§5.1), en wat de twee kanten moeten delen is de VOLGORDE en de
scheidingstekens, niet de hashfunctie. Welke vier velden erin zitten volgt
letterlijk uit §9.1 ("per modus, per kalendermaand; herdownload van dezelfde
ontwerprevisie in dezelfde maand telt niet opnieuw, ook niet in een ander
formaat"), dus formaat, bron, grant_id, design_id en downloaded_at
zitten er bewust NIET in. De maand komt uit amsterdamMaand — nieuw in
amsterdam-dag, want de factuur is Nederlands en de maandgrens ligt in
Europe/Amsterdam (§9.1).
usage-download is het contract van POST /api/v1/usage/download (§5.3,
fase 3 punt 3): de runtime meldt, het portaal antwoordt.
UsageDownloadRequestSchema is DownloadCompletedSchema min bedrijf_id en
bron — afgeleid, niet overgetypt. Die twee horen niet in de body: het
bedrijf volgt uit de grant (en die opzoeking is meteen de plek waar het portaal
controleert of deze aanroep die grant mag melden), en de bron volgt uit de auth
van de aanroeper. Daarmee is ook duidelijk wie de sleutel van §5.4 samenstelt:
de aanroeper kent bedrijf_id niet, dus zet hij hooguit een eigen retry-sleutel
in de header IDEMPOTENCY_KEY_HEADER (idempotency-key) terwijl het portaal
de echte sleutel uitrekent zodra het de grant heeft opgezocht. Het antwoord
({ ok: true, key, duplicate }) geeft hem terug; een duplicaat is een 2xx,
want een tweede melding is de normale uitkomst van een geslaagde retry en geen
fout.
webhooks
Het bericht dat het platform naar een https-adres van de maker post (WENS-14):
de envelop { id, type, versie, aangemaakt_op, data } met als eerste event
bestelling.aangemaakt (versie 1) en het testbericht ping. data draagt
alleen wat de maker in zijn portaal ook al ziet — ordernummer, soort,
omschrijving, naam en adres van de klant, het herrekende bedrag in centen, de
configurator en een portaal_url — en bewust niet de ruwe payload, bestanden
of downloadlinks: het gaat naar een adres dat de maker zelf kiest (AVG, zie het
kopblok van src/webhooks.ts).
Elke bezorging draagt drie headers: Mark2Form-Signature
(t=<unix>,v1=<hex hmac-sha256(geheim, t + "." + ruwe body)>, zoals bij
Stripe), Mark2Form-Event en Mark2Form-Delivery. De maker controleert met
dezelfde functie die wij gebruiken om te ondertekenen:
import { verifieerHandtekening, WEBHOOK_HEADERS } from "@mark2form/contracts/webhooks";
const ruw = await request.text();
const uit = await verifieerHandtekening({
geheim: process.env.MARK2FORM_WEBHOOK_GEHEIM!,
ruweBody: ruw,
header: request.headers.get(WEBHOOK_HEADERS.handtekening),
});
if (!uit.geldig) return new Response("ongeldig", { status: 400 });De functies gebruiken WebCrypto (globalThis.crypto.subtle) en zijn daarom
async; zo draaien ze ook op een edge-runtime, Deno en Bun, zonder dat dit
package een tweede dependency krijgt. Tolerantie: vijf minuten
(WEBHOOK_TOLERANTIE_SECONDEN), vergelijking in constante tijd.
account-status
Het account-statusmodel van een Mark2Form-klant (K02): negen standen in de
volgorde van de reis — account_aangemaakt, email_bevestigd,
software_gekozen, proefgebruik, betaling_vereist, actief,
betaling_open, gepauzeerd, opgezegd — plus de acht statussen die Stripe
voor een subscription kent (STRIPE_ABONNEMENT_STATUSSEN, letterlijk).
accountStatusUitStripe is de pure afleiding daartussen; een onbekende waarde
geeft null, nooit een gok. De vier standen vóór een abonnement leidt hij
bewust niet af: die hangen aan auth en portaalgegevens, en horen in het
portaal — met deze woorden. De Mark2Form-webhook (@mark2form/billing/stripe-m2f)
schrijft de Stripe-status letterlijk in subscriptions.status (migratie 0077),
zodat het portaal hem met deze functie kan lezen.
De registratie van het logodossier (admin W01)
Acht submodules voor de Mark2Form-admin (uitvoeringsopdracht, contracten
C01–C11). Het ontwerp en de keuzes staan in
docs/uitvoering/mark2form-admin/contracten-w01.md; hier alleen wat een
consument moet weten.
| Ingang | Wat |
|---|---|
| registratie-basis | gebrande ID's (RunId, ArtifactId, …), UtcTijdstipSchema (Z of +00:00), Sha256HexSchema, ActorSchema, PROCESSING_ENGINES (de acht van InspectorEngine plus orkestratie, met een typeslot), coördinatenruimten, MeetwaardeSchema, TimingMetingSchema |
| canonical-json | canoniekeJson en canoniekeJsonSha256 — canonical-json@1, byte-gelijk aan de bestaande sorteer-serialisaties voor elke waarde die ze aanneemt; gooit op NaN, Date, undefined in een array, __proto__ |
| engine-release | ReleaseManifestSchema (strikt), manifestIdVan (manifest:<sha256>), ReleaseBindingSchema, de levensloop van een release, registratie en toewijzing |
| processing-run | run en poging met hun statusmachines, capture_status, cacheherkomst, en leesRunCorrelatie voor events.metadata.run_id |
| processing-step | StepExecutionSchema, stapcodes, verliesboekhouding, afhankelijkheden |
| artifact-reference | ArtifactSchema met precies één eigenaar, beschikbaarheid, bewaring en exportbinding |
| quality-case | verbeterzaken, vermoede tegenover bewezen oorzaak, annotaties met ruimte |
| experiment | experiment, heruitvoerbaarheid en afgeleide vergelijkbaarheid |
Twee regels die overal gelden: onbekend blijft onbekend (een historisch
record krijgt unknown_legacy, nooit "de huidige"; er staat nergens een
.default()), en kleine gemarkeerde unies zijn strikt (een actor, eigenaar
of binding met een veld te veel wordt geweigerd, niet gesnoeid).
De bevoegdheden van het beheer (admin W18a)
admin-capability legt de zeven capabilities van C10 vast (admin.read,
customer.manage, diagnostics.run, quality.review, release.manage,
billing.adjust, data.erase), de bundels die er rollen van maken
(beheerder = alles, support, onderzoeker, releasebeheer, lezer),
capabilitiesVanBundels (de vereniging, in vaste volgorde, een onbekende bundel
geeft niets) en AUDIT_RESULTATEN (aangevraagd voor de vooraf-regel van een
duurzame audit, plus gelukt | geweigerd | mislukt). Dezelfde lijsten staan als
CHECK in de concept-migraties 0097 en 0099; admin-capability.test.ts legt ze
naast elkaar. Wie wat mag beslist het portaal (eisCapability), niet dit package.
Ontwerprevisie en exportbewijs (admin W07)
export-bewijs legt C06 vast: RevisieVastleggingSchema (een
servergevalideerde revisie met bron-sha256, bronrun — registered,
niet_gekoppeld of unknown_legacy, nooit een browserclaim —, fysieke maat,
productiekeuzes en de build die valideerde), ExportPogingSchema (één keer
maken, met per bestand de sha256, het artifact, de opslagstand, of het
geleverd is en de controle met haar profiel), PreviewBindingSchema met
vergelijkPreviewMetExport (zelfde, andere of onbekend), de
rijvertalingen voor de concept-tabellen design_revisions, export_pogingen
en export_poging_bestanden, en EXPORT_MEDIATYPEN (de ene tabel mediatype
per uitvoerformaat). Een reconstructie is altijd een eigen poging met een
eigen bestand; zij heet nooit het historische bestand. De artifactsoort
design_revision (eigenaar: de revisie) draagt de canonieke inhoud achter de
hash.
Consumeren
In de monorepo — gewoon een workspace-dependency, en je leest de TS-bron:
{ "dependencies": { "@mark2form/contracts": "workspace:*" } }Buiten de monorepo (het Astro-portaal, de marketingsites) — de gepubliceerde
versie van npm, gebouwd met tsup (ESM + .d.ts):
npm install @mark2form/contractsDat verschil zie je niet in de code: package.json wijst in de werkkopie naar
./src/... en via publishConfig in de tarball naar ./dist/.... Niemand hoeft
in de monorepo eerst te bouwen, en niemand buiten de monorepo krijgt ongebouwde
TypeScript.
Smal importeren mag — elke submodule heeft een eigen ingang, zodat een consument die alleen het health-schema wil niet het hele neon-domein in zijn bundel krijgt:
import { HealthSchema } from "@mark2form/contracts/health";
import { OpsOverzichtSchema } from "@mark2form/contracts/ops-overzicht";
import { STUDIO_MAX_PRESETS } from "@mark2form/contracts/config/studio";
import { amsterdamMidnight } from "@mark2form/contracts/amsterdam-dag";
import { BrandingSchema, DEFAULT_BRANDING } from "@mark2form/contracts/branding";
import { tenantConfigVelden } from "@mark2form/contracts/tenant-config-schema";
import { EmbedJwtClaimsSchema, type EmbedJwtClaims } from "@mark2form/contracts/embed-jwt";
import { groepVanEvent } from "@mark2form/contracts/events";
import { ENDPOINTS, zetWildcardCors } from "@mark2form/contracts/endpoints";
import type { ProductModuleBeschrijving } from "@mark2form/contracts/product-module";
import { downloadIdempotencyInvoer } from "@mark2form/contracts/outbox";
import { UsageDownloadRequestSchema } from "@mark2form/contracts/usage-download";
import { verifieerHandtekening, WebhookBerichtSchema } from "@mark2form/contracts/webhooks";De volledige lijst staat in het exports-blok van package.json; . levert
alles.
Versiebeleid
Semver via Changesets (§5.1):
| Wijziging | Bump | |---|---| | een veld, schema, constante of submodule erbij | minor | | een veld weg, hernoemd, of van type veranderd; een strengere regel op iets dat bestond | major | | tekst, commentaar, een test | patch |
De schema's zijn daarom additief-vriendelijk: z.looseObject laat onbekende
sleutels staan in plaats van ze te snoeien, zodat een consument op een oudere
contractversie een nieuwer antwoord gewoon kan lezen. Runtime, portaal en
cockpit worden niet op hetzelfde moment gedeployed.
CONTRACT_VERSION is de versie van dít package als constante. Hij wordt
gelijkgehouden door scripts/contract-versie-sync.mjs, dat als tweede helft van
pnpm version-packages draait; src/__tests__/tenant-config-contract.test.ts
legt de twee naast elkaar en faalt zodra iemand dat overslaat.
Publiceren
Je publiceert niets met de hand. De flow is:
- Een PR die dit package raakt draagt een changeset (
pnpm changeset). - Na de merge naar
mainopent de release-workflow de PR "chore(release): versies bijwerken": die bumptpackage.json, schrijft deCHANGELOG.mden synchroniseertCONTRACT_VERSION. - Pascal mergt die PR. Dán pas bouwt en publiceert de workflow naar npm
(
@mark2form, public).
Wie een versie nodig heeft die nog niet gepubliceerd is, wacht dus op stap 3 — dat is met opzet: een contract dat op npm staat kan niet meer teruggenomen worden.
Wijzigen
Een contractwijziging raakt per definitie twee kanten. De spelregels (§5.1):
- alleen via een PR met changeset, goedgekeurd door de contract-eigenaar
(CODEOWNERS wijst
packages/contractsaan als Pascal-review, §10.4); - elke wijziging heeft een test in beide consumerende apps — niet alleen hier;
- geen enkele import uit een ander package erbij;
pnpm boundariesbewaakt dat.
Tests
pnpm --filter @mark2form/contracts test # vitest
pnpm --filter @mark2form/contracts typecheck # tsc --noEmit
pnpm --filter @mark2form/contracts build # tsup → dist/De proef die het meest oplevert is de bevroren sleutellijst voor
PRODUCTION_FIELDS: die bevriest de 35 mapper-velden en de 23 portaalvelden van
deze kant. Het portaal draagt de tegenhanger, gelegd naast de
config_schema-snapshot uit zijn migratieketen. Zodra het portaal dit package
als dependency neemt zijn dat twee tests op één bron en kan de drift van
september niet meer ontstaan.
