Härda enligt revision 2: stäng schemat, ta bort dubbelregeln
C-5. granskaHändelse itererade schemats nycklar och aldrig händelsens, så okända fält accepterades och sparades ordagrant. Två garantier vilade på motsatsen: krypto-shreddingen skyddar en fast lista av fältnamn, så personuppgifter på en vanlig observation krypterades aldrig och överlevde raderingen — och delningsfiltret är typnivå, så samma fält gick ut i kundens delningslänk. Schemat är nu stängt, med varje valfritt fält deklarerat. Det gäller även `kalla`: protokollinläsningen fungerade bara därför att schemat var öppet. Avslaget är hårt, inte en tyst strykning. Verifierat mot riktig trafik — samtliga händelser som klienten faktiskt producerar passerar. M-7. Klienten upprepar inte längre grindens regel utan anropar grinda() och visar dess egna hinder. Villkoret hade glidit isär två gånger utan att något test märkte det. Det avslöjade omedelbart ett verkligt fel i grinden: den krävde textresultat även på kontroller vars krav är foto — trots att gränssnittet märker fältet "Observation (valfritt)". Servern hade alltså nekat avslut på nästan varje riktigt ärende, och det syntes inte så länge klienten hade ett eget och mildare villkor. Evidens graderas nu efter kontrollens eget krav. M-8. Protokollinläsningen svarar med utfall per händelse i stället för en siffra, 207 vid delvis lyckad inläsning, innehållshärledda id:n i stället för klockan, och en transaktion runt hela importen. M-9. En genererad webhookhemlighet lämnas ut en gång vid skapandet. m-8. Profilens vägslagning begränsas till egna egenskaper. m-10. Ett mätvärde som kommer ur en kontroll redovisas en gång. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
@@ -284,3 +284,169 @@ describe("metodikernas mätkontroller producerar mätevidens", () => {
|
|||||||
expect(underlagFinns(arende, "Mätresultat")).toBe(true);
|
expect(underlagFinns(arende, "Mätresultat")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- C-5 · Schemat är stängt --------------------------------------------
|
||||||
|
//
|
||||||
|
// Revision 2 fann att granskaHändelse itererade schemats nycklar och
|
||||||
|
// aldrig händelsens. Följden: vilket okänt fält som helst accepterades och
|
||||||
|
// sparades ordagrant. Ett registreringsnummer på en vanlig observation
|
||||||
|
// hamnade aldrig i krypteringslistan, överlevde därför raderingen, och
|
||||||
|
// gick ut i kundens delningslänk eftersom delningsfiltret arbetar på
|
||||||
|
// typnivå.
|
||||||
|
//
|
||||||
|
// Testerna nedan låser tre saker: att okända fält avvisas, att avslaget
|
||||||
|
// är hårt (inte en tyst strykning), och att deklarationen inte kan glida
|
||||||
|
// från domänmodellen åt något håll.
|
||||||
|
describe("händelseschemat är stängt", () => {
|
||||||
|
it("avvisar påhängda fält på en i övrigt giltig händelse", async () => {
|
||||||
|
const { granskaHändelse } = await import("../../../../services/gemensam/handelser.mjs");
|
||||||
|
const fel = granskaHändelse({
|
||||||
|
typ: "observation",
|
||||||
|
text: "Kontroll av bromsok.",
|
||||||
|
vin: "YV1DZ8256F2123456",
|
||||||
|
personnummer: "19800101-1234",
|
||||||
|
});
|
||||||
|
expect(fel).toMatch(/okända fält/);
|
||||||
|
expect(fel).toContain("vin");
|
||||||
|
expect(fel).toContain("personnummer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("avslaget är hårt — händelsen skrivs inte utan de okända fälten", async () => {
|
||||||
|
// En tyst strykning vore värre än att spara skräpet: anroparen tror
|
||||||
|
// att värdet finns i loggen och upptäcker motsatsen när det behövs.
|
||||||
|
const { tillPost } = await import("../../../../services/gemensam/handelser.mjs");
|
||||||
|
const { post, fel } = tillPost(
|
||||||
|
{ id: "a1", handelse: { typ: "observation", text: "Kontroll.", vin: "ABC123" } },
|
||||||
|
{ sub: "u1", namn: "Anna", org: "o1" },
|
||||||
|
);
|
||||||
|
expect(post).toBeUndefined();
|
||||||
|
expect(fel).toMatch(/okända fält/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("systemets egna fält passerar", async () => {
|
||||||
|
const { granskaHändelse } = await import("../../../../services/gemensam/handelser.mjs");
|
||||||
|
expect(
|
||||||
|
granskaHändelse({
|
||||||
|
typ: "observation",
|
||||||
|
text: "Kontroll av bromsok.",
|
||||||
|
anvandarId: "u1",
|
||||||
|
registrerad_tidpunkt: "2026-01-01T10:00:00.000Z",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("protokollinläsningens härkomstfält är deklarerat", async () => {
|
||||||
|
// Integrationen fungerade tidigare bara därför att schemat var öppet.
|
||||||
|
// Stängs schemat utan att `kalla` deklareras slutar den fungera.
|
||||||
|
const { granskaHändelse } = await import("../../../../services/gemensam/handelser.mjs");
|
||||||
|
const { protokollTillHandelser } = await import("../../../../services/gemensam/integration.mjs");
|
||||||
|
const handelser = protokollTillHandelser(
|
||||||
|
{ dtcs: [{ code: "P0420", description: "Katalysator under tröskel" }], liveData: [{ name: "Lambda", value: "0,81", unit: "V" }] },
|
||||||
|
{ felkoder: { vag: "dtcs", kod: "code", text: "description" },
|
||||||
|
matvarden: { vag: "liveData", beskrivning: "name", varde: "value", enhet: "unit" } },
|
||||||
|
"Diagnosinstrument, bås 3",
|
||||||
|
);
|
||||||
|
expect(handelser.length).toBe(2);
|
||||||
|
for (const h of handelser) expect(granskaHändelse(h)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("varje valfritt fält i schemat finns i domänmodellen", async () => {
|
||||||
|
const { VALFRIA_FÄLT } = await import("../../../../services/gemensam/handelser.mjs");
|
||||||
|
const domain = readFileSync("src/felsokning/domain.ts", "utf8");
|
||||||
|
const ärvda = ["bilagaId", "bilagaHash", "dataUrl"];
|
||||||
|
const avvikelser: string[] = [];
|
||||||
|
for (const [typ, falt] of Object.entries(VALFRIA_FÄLT as Record<string, string[]>)) {
|
||||||
|
const start = domain.indexOf(`typ: "${typ}"`);
|
||||||
|
const block = domain.slice(start, start + 900);
|
||||||
|
for (const f of falt) {
|
||||||
|
if (ärvda.includes(f) || f === "kalla") continue; // Bilaga respektive härkomst
|
||||||
|
if (!new RegExp(`\\b${f}\\?:`).test(block)) avvikelser.push(`${typ}.${f}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(avvikelser).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("varje valfritt fält i domänmodellen är deklarerat i schemat", async () => {
|
||||||
|
// Den riktningen är den farliga: ett nytt valfritt fält i modellen
|
||||||
|
// som ingen deklarerar avvisas i produktion, inte i testet.
|
||||||
|
const { HÄNDELSESCHEMA, VALFRIA_FÄLT } = await import("../../../../services/gemensam/handelser.mjs");
|
||||||
|
const domain = readFileSync("src/felsokning/domain.ts", "utf8");
|
||||||
|
// Unionen måste avgränsas: annars svämmar den sista medlemmen in i
|
||||||
|
// resten av filen och testet rapporterar andra typers fält.
|
||||||
|
const fran = domain.indexOf("export type Handelse =");
|
||||||
|
const till = domain.indexOf("\n\nexport ", fran);
|
||||||
|
const union = domain.slice(fran, till > 0 ? till : undefined);
|
||||||
|
// `(?!\s*\|)` sållar bort ai_svar-radernas egen typunion
|
||||||
|
// (`typ: "observation" | "verifierat" | …`), som annars läses som en
|
||||||
|
// ny unionsmedlem och förskjuter alla block efter den.
|
||||||
|
const traffar = [...union.matchAll(/typ: "([a-z_]+)"(?!\s*\|)/g)];
|
||||||
|
const typer = traffar.map((m) => m[1]);
|
||||||
|
const avvikelser: string[] = [];
|
||||||
|
|
||||||
|
traffar.forEach((traff, i) => {
|
||||||
|
const typ = traff[1];
|
||||||
|
const start = traff.index!;
|
||||||
|
const nasta = i + 1 < traffar.length ? traffar[i + 1].index! : union.length;
|
||||||
|
const block = union.slice(start, nasta);
|
||||||
|
const deklarerade = new Set([
|
||||||
|
...Object.keys((HÄNDELSESCHEMA as Record<string, object>)[typ] ?? {}),
|
||||||
|
...((VALFRIA_FÄLT as Record<string, string[]>)[typ] ?? []),
|
||||||
|
]);
|
||||||
|
for (const m of block.matchAll(/^\s+(\w+)\?:/gm)) {
|
||||||
|
if (!deklarerade.has(m[1])) avvikelser.push(`${typ}.${m[1]}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(avvikelser).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Fotokontroller verifieras av foton ----------------------------------
|
||||||
|
//
|
||||||
|
// QUALITY-AUDIT-2 · M-7, funnet först när klienten slutade ha en egen,
|
||||||
|
// mildare mening om avslut. Grinden krävde ett textresultat även på
|
||||||
|
// kontroller vars krav är foto — trots att gränssnittet märker det fältet
|
||||||
|
// "Observation (valfritt)". Servern hade alltså nekat avslut på nästan
|
||||||
|
// varje riktigt ärende, och teknikern fått veta det först vid synk.
|
||||||
|
describe("grinden räknar rätt sorts evidens per kontrolltyp", () => {
|
||||||
|
const kontrollHandelser = (metodik: { steg: { id: string; kontroller?: { id: string; krav?: string; text: string }[] }[] }, medResultat: boolean) =>
|
||||||
|
metodik.steg.flatMap((s) =>
|
||||||
|
(s.kontroller ?? []).map((k) => ({
|
||||||
|
typ: "kontroll_utford",
|
||||||
|
stegId: s.id,
|
||||||
|
kontrollId: k.id,
|
||||||
|
text: k.text,
|
||||||
|
...(medResultat && k.krav !== "foto" ? { resultat: "Uppmätt inom tolerans." } : {}),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
it("en fotokontroll utan textresultat är inte ett hinder när fotot finns", async () => {
|
||||||
|
const { grinda } = await import("../../../../services/gemensam/grind.mjs");
|
||||||
|
const { VIBRATION } = await import("../../../../services/gemensam/metodiker.mjs");
|
||||||
|
const fotokrav = VIBRATION.steg.flatMap((s: { kontroller?: { krav?: string }[] }) =>
|
||||||
|
(s.kontroller ?? []).filter((k) => k.krav === "foto"),
|
||||||
|
).length;
|
||||||
|
expect(fotokrav).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const logg = [
|
||||||
|
...kontrollHandelser(VIBRATION, true),
|
||||||
|
...Array.from({ length: fotokrav }, (_, i) => ({ typ: "foto", beskrivning: `Hjul ${i + 1}` })),
|
||||||
|
];
|
||||||
|
const hinder = grinda(logg, VIBRATION).map((h: { id: string }) => h.id);
|
||||||
|
expect(hinder).not.toContain("metodik_kontroller");
|
||||||
|
expect(hinder).not.toContain("foton");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("men saknas fotona spärrar det fortfarande", async () => {
|
||||||
|
const { grinda } = await import("../../../../services/gemensam/grind.mjs");
|
||||||
|
const { VIBRATION } = await import("../../../../services/gemensam/metodiker.mjs");
|
||||||
|
const hinder = grinda(kontrollHandelser(VIBRATION, true), VIBRATION).map((h: { id: string }) => h.id);
|
||||||
|
expect(hinder).toContain("foton");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("en mät- eller kommentarkontroll utan resultat spärrar fortfarande", async () => {
|
||||||
|
const { grinda } = await import("../../../../services/gemensam/grind.mjs");
|
||||||
|
const { VIBRATION } = await import("../../../../services/gemensam/metodiker.mjs");
|
||||||
|
const hinder = grinda(kontrollHandelser(VIBRATION, false), VIBRATION).map((h: { id: string }) => h.id);
|
||||||
|
expect(hinder).toContain("metodik_kontroller");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -266,13 +266,31 @@ describe("klientens avslutsvillkor täcker hela grindens åtgärdskedja", () =>
|
|||||||
expect(await gateHinder(med)).not.toContain("kundbeslut");
|
expect(await gateHinder(med)).not.toContain("kundbeslut");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("klienten kräver samma sak — kundbeslut ingår i underlagKlart", () => {
|
// QUALITY-AUDIT-2 · M-7. Testet krävde tidigare att klienten räknade
|
||||||
|
// upp grindens villkor. Det var fel sorts skydd: det låser bara fast
|
||||||
|
// de villkor någon råkade tänka på, och nästa villkor som läggs till i
|
||||||
|
// grinden glider isär precis som de två föregående gjorde.
|
||||||
|
//
|
||||||
|
// Nu krävs i stället att klienten inte har någon egen mening. Ett
|
||||||
|
// uttryck kan inte glida från sig självt.
|
||||||
|
it("klienten frågar grinden i stället för att upprepa den", () => {
|
||||||
const kod = readFileSync("src/pages/felsokning/ArendeSida.tsx", "utf8");
|
const kod = readFileSync("src/pages/felsokning/ArendeSida.tsx", "utf8");
|
||||||
const rad = kod.match(/const underlagKlart =[\s\S]*?;/)?.[0] ?? "";
|
expect(kod).toContain('from "../../../../services/gemensam/grind.mjs"');
|
||||||
expect(rad).toContain("reproducering(arende)");
|
|
||||||
expect(rad).toContain("felorsaker(arende)");
|
const rad = kod.match(/const kanAvslutas =[\s\S]{0,200}?;/)?.[0] ?? "";
|
||||||
expect(rad).toContain("kvalitetskontroll(arende)");
|
expect(rad).toContain("hinder.length === 0");
|
||||||
expect(rad).toContain("kundbeslut(arende)");
|
|
||||||
|
// Ingen egen uppräkning av grindens villkor får finnas kvar.
|
||||||
|
const uppraknat = kod.match(/const underlagKlart =[\s\S]{0,300}?;/)?.[0] ?? "";
|
||||||
|
for (const villkor of ["reproducering(arende)", "felorsaker(arende)", "kvalitetskontroll(arende)", "kundbeslut(arende)"]) {
|
||||||
|
expect(uppraknat, `klienten upprepar grindens villkor: ${villkor}`).not.toContain(villkor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hindren som visas är grindens egna, med id och rubrik", () => {
|
||||||
|
const kod = readFileSync("src/pages/felsokning/ArendeSida.tsx", "utf8");
|
||||||
|
expect(kod).toMatch(/function Avslutshinder\(\{ hinder \}/);
|
||||||
|
expect(kod).toContain("h.rubrik");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,14 @@ export type Handelse =
|
|||||||
beslut: "godkant" | "avbojt" | "delvis";
|
beslut: "godkant" | "avbojt" | "delvis";
|
||||||
kanal: string;
|
kanal: string;
|
||||||
kommentar?: string;
|
kommentar?: string;
|
||||||
|
/**
|
||||||
|
* Vem hos kunden som lämnade beskedet. Personuppgift: krypteras per
|
||||||
|
* ärendenyckel och försvinner vid radering (IDENTIFIERANDE i
|
||||||
|
* services/gemensam/personuppgifter.mjs). Fältet stod tidigare i
|
||||||
|
* krypteringslistan utan att finnas i modellen — nu stämmer
|
||||||
|
* modellen, schemat och krypteringen överens.
|
||||||
|
*/
|
||||||
|
kontaktperson?: string;
|
||||||
}
|
}
|
||||||
// Åtgärdsfasen: vad som faktiskt gjordes — eller varför ingen åtgärd
|
// Åtgärdsfasen: vad som faktiskt gjordes — eller varför ingen åtgärd
|
||||||
// utfördes (kunden avböjde, väntar på delar …). Kopplas till den
|
// utfördes (kunden avböjde, väntar på delar …). Kopplas till den
|
||||||
|
|||||||
@@ -155,11 +155,25 @@ export function ejKontrollerat(arende: Arende, metodik: Metodik): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function observationer(arende: Arende): string[] {
|
export function observationer(arende: Arende): string[] {
|
||||||
|
// En kontroll vars krav är ett mätvärde loggar både mätvärdet och
|
||||||
|
// kontrollen, så att mätningen finns som mätevidens. Rapporten ska
|
||||||
|
// ändå bara redovisa värdet en gång: kontrollen bär det redan under
|
||||||
|
// Utförda kontroller (QUALITY-AUDIT-2 · m-10).
|
||||||
|
const iKontroll = new Set(
|
||||||
|
arende.handelser
|
||||||
|
.map((p) => p.handelse)
|
||||||
|
.filter((h) => h.typ === "kontroll_utford" && h.resultat)
|
||||||
|
.map((h) => (h as { text: string; resultat: string }).text + " " + (h as { resultat: string }).resultat),
|
||||||
|
);
|
||||||
|
|
||||||
const resultat: string[] = [];
|
const resultat: string[] = [];
|
||||||
for (const post of arende.handelser) {
|
for (const post of arende.handelser) {
|
||||||
const h = post.handelse;
|
const h = post.handelse;
|
||||||
if (h.typ === "observation") resultat.push(h.text);
|
if (h.typ === "observation") resultat.push(h.text);
|
||||||
if (h.typ === "matvarde") resultat.push(`${h.beskrivning}: ${h.varde}${h.enhet ? ` ${h.enhet}` : ""}`);
|
if (h.typ === "matvarde") {
|
||||||
|
if (iKontroll.has(h.beskrivning + " " + h.varde)) continue;
|
||||||
|
resultat.push(`${h.beskrivning}: ${h.varde}${h.enhet ? ` ${h.enhet}` : ""}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return resultat;
|
return resultat;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { fasFor, klaraFaser } from "../../../../services/gemensam/faser.mjs";
|
|||||||
import { Fasrad } from "@/alva/komponenter";
|
import { Fasrad } from "@/alva/komponenter";
|
||||||
import { Slutsatspanel } from "@/felsokning/Slutsats";
|
import { Slutsatspanel } from "@/felsokning/Slutsats";
|
||||||
import { sammanfatta } from "../../../../services/gemensam/sammanfattning.mjs";
|
import { sammanfatta } from "../../../../services/gemensam/sammanfattning.mjs";
|
||||||
import { granskaSlutsats } from "../../../../services/gemensam/motivering.mjs";
|
import { grinda } from "../../../../services/gemensam/grind.mjs";
|
||||||
import { arendebeteckning, fasDefinition } from "@/alva/system";
|
import { arendebeteckning, fasDefinition } from "@/alva/system";
|
||||||
import {
|
import {
|
||||||
arAvslutat,
|
arAvslutat,
|
||||||
@@ -1255,36 +1255,31 @@ function GuideFlik({
|
|||||||
|
|
||||||
const senasteAiSvar = [...arende.handelser].reverse().find((p) => p.handelse.typ === "ai_svar");
|
const senasteAiSvar = [...arende.handelser].reverse().find((p) => p.handelse.typ === "ai_svar");
|
||||||
|
|
||||||
// Avslut kräver hela kedjan: symptomverifiering, felorsaksanalys,
|
// QUALITY-AUDIT-2 · M-7. Klienten upprepar inte längre grindens regel.
|
||||||
// dokumenterad åtgärd (eller motiverat uteblivande) och — när en
|
|
||||||
// åtgärd faktiskt utförts — kvalitetskontroll av att symptomet är borta.
|
|
||||||
const atgardsposter = atgarder(arende);
|
|
||||||
const utfordAtgard = atgardsposter.some((p) => p.handelse.typ === "atgard_utford" && p.handelse.utford);
|
|
||||||
|
|
||||||
// Underlaget: det som gör att en slutsats över huvud taget går att
|
|
||||||
// motivera. Innan detta är på plats vore varför-frågan en begäran om
|
|
||||||
// en gissning.
|
|
||||||
//
|
//
|
||||||
// Kundbeskedet hör till kedjan när arbete faktiskt utförts. Grinden på
|
// Den gjorde det tidigare, för att kunna gråa ut avslutsknappen, och
|
||||||
// servern har alltid krävt det; klienten gjorde det inte, med följden
|
// villkoret gled isär från grinden två gånger: först saknades kravet på
|
||||||
// att ett ärende gick att stänga på skärmen och nekas först vid synk.
|
// en slutsats, sedan kravet på kundens besked. Ingen av gångerna
|
||||||
// Samma sorts glapp som slutsatsen hade — hittat genom att köra tio
|
// fångades av testerna — bägge hittades av att köra tio ärenden hela
|
||||||
// ärenden hela vägen och jämföra mot grinden.
|
// vägen. Två självständiga uttryck för en regel glider alltid; frågan
|
||||||
const underlagKlart =
|
// är bara när det upptäcks.
|
||||||
!!reproducering(arende) &&
|
//
|
||||||
felorsaker(arende).length > 0 &&
|
// Nu ställs frågan till grinden själv, med den lokala loggen. Samma
|
||||||
atgardsposter.length > 0 &&
|
// funktion som servern kör, så svaret kan inte skilja sig. Att den
|
||||||
(!utfordAtgard || (!!kvalitetskontroll(arende) && !!kundbeslut(arende)));
|
// därmed blir strängare är avsikten: det servern skulle neka ska synas
|
||||||
|
// på skärmen där arbetet utförs, inte vid synk när bilen har åkt.
|
||||||
// ALVA-RULE-200. Samma granskning som kvalitetsgrinden på servern kör
|
const hinder = grinda(
|
||||||
// — avsiktligt samma funktion, inte en klientkopia av regeln. En
|
|
||||||
// tekniker ska aldrig få veta vid synk att ärendet inte gick att
|
|
||||||
// stänga; hindret ska synas på skärmen där arbetet utförs.
|
|
||||||
const slutsatsbrister = granskaSlutsats(
|
|
||||||
[...arende.handelser].reverse().find((p) => p.handelse.typ === "slutsats")?.handelse,
|
|
||||||
arende.handelser.map((p) => p.handelse),
|
arende.handelser.map((p) => p.handelse),
|
||||||
);
|
metodik,
|
||||||
const kanAvslutas = underlagKlart && slutsatsbrister.length === 0;
|
) as { id: string; rubrik: string; detalj?: string }[];
|
||||||
|
|
||||||
|
const kanAvslutas = hinder.length === 0;
|
||||||
|
|
||||||
|
// Slutsatspanelen visas när underlaget är helt — alltså när det enda
|
||||||
|
// som återstår är varför-frågan. Att fråga tidigare vore att be om en
|
||||||
|
// gissning; att fråga först vid knappen vore att neka någon som redan
|
||||||
|
// är klar.
|
||||||
|
const underlagKlart = hinder.every((h) => h.id.startsWith("slutsats_"));
|
||||||
|
|
||||||
if (avslutat) {
|
if (avslutat) {
|
||||||
return (
|
return (
|
||||||
@@ -1350,7 +1345,7 @@ function GuideFlik({
|
|||||||
<StorKnapp variant="fara" disabled={!kanAvslutas} onClick={() => skicka({ typ: "arende_avslutat", signatur: anvandare })}>
|
<StorKnapp variant="fara" disabled={!kanAvslutas} onClick={() => skicka({ typ: "arende_avslutat", signatur: anvandare })}>
|
||||||
Avsluta felsökning
|
Avsluta felsökning
|
||||||
</StorKnapp>
|
</StorKnapp>
|
||||||
{!kanAvslutas && <Avslutshinder underlagKlart={underlagKlart} brister={slutsatsbrister} />}
|
{!kanAvslutas && <Avslutshinder hinder={hinder} />}
|
||||||
</>
|
</>
|
||||||
) : steg.sparr ? (
|
) : steg.sparr ? (
|
||||||
// Säkerhetsspärr. Metodiken går inte vidare — svaret är ett
|
// Säkerhetsspärr. Metodiken går inte vidare — svaret är ett
|
||||||
@@ -1423,7 +1418,7 @@ function GuideFlik({
|
|||||||
</Panel>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!kanAvslutas && <Avslutshinder underlagKlart={underlagKlart} brister={slutsatsbrister} />}
|
{!kanAvslutas && <Avslutshinder hinder={hinder} />}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<StorKnapp variant="sekundar" onClick={() => setVisaOverlamning(true)}>
|
<StorKnapp variant="sekundar" onClick={() => setVisaOverlamning(true)}>
|
||||||
Lämna över arbete
|
Lämna över arbete
|
||||||
@@ -1443,30 +1438,25 @@ function GuideFlik({
|
|||||||
/**
|
/**
|
||||||
* Varför avslut är spärrat — i klartext, på skärmen, medan arbetet pågår.
|
* Varför avslut är spärrat — i klartext, på skärmen, medan arbetet pågår.
|
||||||
*
|
*
|
||||||
* Ett hinder som bara säger "kraven är inte uppfyllda" tvingar teknikern
|
* Listan är grindens egen. Det är hela poängen: teknikern läser exakt de
|
||||||
* att leta. Här står vad som fattas, i den ordning det behöver åtgärdas:
|
* hinder som servern skulle svara med, vid den tidpunkt då de fortfarande
|
||||||
* först underlaget, sedan varför-frågan.
|
* går att åtgärda. Ett hinder som bara säger "kraven är inte uppfyllda"
|
||||||
|
* tvingar teknikern att leta.
|
||||||
*/
|
*/
|
||||||
function Avslutshinder({ underlagKlart, brister }: { underlagKlart: boolean; brister: { falt: string; text: string }[] }) {
|
function Avslutshinder({ hinder }: { hinder: { id: string; rubrik: string; detalj?: string }[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-2 border-l-2 border-[#9A6700] bg-[#FFFBF0] px-4 py-4">
|
<div className="mb-2 border-l-2 border-[#9A6700] bg-[#FFFBF0] px-4 py-4">
|
||||||
<p className="mb-2 text-[12px] font-semibold uppercase tracking-[0.08em] text-[#9A6700]">
|
<p className="mb-2 text-[12px] font-semibold uppercase tracking-[0.08em] text-[#9A6700]">
|
||||||
Ärendet kan inte avslutas ännu
|
Ärendet kan inte avslutas ännu
|
||||||
</p>
|
</p>
|
||||||
{!underlagKlart && (
|
<ul className="ml-4 list-disc text-[13px] leading-[20px] text-[#333333]">
|
||||||
<p className="mb-2 text-[13px] leading-[20px] text-[#333333]">
|
{hinder.map((h) => (
|
||||||
Underlaget är ofullständigt: avslut kräver symptomverifiering, felorsaksanalys, dokumenterad åtgärd och —
|
<li key={h.id}>
|
||||||
vid utförd åtgärd — kvalitetskontroll av att symptomet är borta samt kundens besked på åtgärdsförslaget.
|
{h.rubrik}
|
||||||
Se panelerna nedan.
|
{h.detalj && <span className="text-[#4A5560]"> — {h.detalj}</span>}
|
||||||
</p>
|
</li>
|
||||||
)}
|
))}
|
||||||
{brister.length > 0 && (
|
</ul>
|
||||||
<ul className="ml-4 list-disc text-[13px] leading-[20px] text-[#333333]">
|
|
||||||
{brister.map((b) => (
|
|
||||||
<li key={b.falt}>{b.text}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -404,4 +404,67 @@ the metrics selection is, in this reviewer's experience, ahead of the segment.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Remediation status
|
||||||
|
|
||||||
|
Recorded after the fixes were implemented. Each entry states what was actually
|
||||||
|
changed, so a re-audit can verify rather than take it on trust.
|
||||||
|
|
||||||
|
| # | Status | What changed |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **C-5** | ✅ Closed | `granskaHändelse` now iterates the *event's* keys as well as the schema's and rejects anything undeclared. `VALFRIA_FÄLT` declares every optional field per type, including `kalla` — the provenance field the protocol path depends on, which previously worked only because the schema was open. The rejection is hard, not a silent strip: a caller that believes it stored a value is never told it succeeded. Two structural tests lock the declaration against the domain model in both directions, so neither a new optional field in the model nor a stale declaration can drift. Verified against real traffic: all 41 events produced by driving a case through the interface pass the closed schema. |
|
||||||
|
| **M-7** | ✅ Closed | The client no longer restates the rule. It calls `grinda()` with the local log and disables closing when the obstacle list is non-empty, rendering the gate's own obstacles as the explanation. The old test — which required the client to enumerate the gate's conditions — was inverted: it now requires the client to have no opinion of its own, because an expression cannot drift from itself. **This immediately exposed a real gate defect** (see below). |
|
||||||
|
| **M-8** | ✅ Closed | Per-event outcomes replace the count: rejected events are returned with index, type and reason, and a partial import answers `207`, not `200`. Ids derive from a SHA-256 of case, source, index and content, which removes the millisecond collision and makes re-uploading the same protocol idempotent rather than duplicating. The whole import runs in one transaction. |
|
||||||
|
| **M-9** | ✅ Closed | A generated secret is returned once at creation with an explicit note that it will not be shown again. A caller-supplied secret is still never echoed. |
|
||||||
|
| **m-8** | ✅ Closed | Profile path lookups are restricted to own enumerable properties, so `__proto__` and `constructor.prototype` are unreachable from an external profile. |
|
||||||
|
| **m-9** | ◐ Open | The portal remains a presentation shell with an authenticating-looking login. Deliberately deferred: the honest fix is to gate the route behind the real platform session, which is a product decision about what the portal is for. |
|
||||||
|
| **m-10** | ✅ Closed | `observationer()` suppresses a `matvarde` whose description and value already appear as a check result, so a measurement taken through the methodology is reported once. |
|
||||||
|
|
||||||
|
### The defect M-7 uncovered
|
||||||
|
|
||||||
|
Removing the client's independent condition did what the finding predicted it
|
||||||
|
would: it made the server's actual answer visible, and the server's answer was
|
||||||
|
wrong.
|
||||||
|
|
||||||
|
`grinda` required a text result on **every** methodology check — including
|
||||||
|
checks whose requirement is `foto`, whose evidence is the photograph, and whose
|
||||||
|
text field the interface explicitly labels *"Observation (valfritt)"*. The gate
|
||||||
|
therefore refused to close nearly every real case, because most methodologies
|
||||||
|
contain photo checks and most technicians leave an optional field empty.
|
||||||
|
|
||||||
|
This had been true since the gate moved server-side. It was invisible for one
|
||||||
|
reason only: the client had its own, weaker condition, so the close button lit
|
||||||
|
up and the refusal would have arrived at sync. It is the exact failure mode M-7
|
||||||
|
describes, and it was found within minutes of removing the duplicate rule.
|
||||||
|
|
||||||
|
The gate now grades evidence by the check's own requirement: a photo check is
|
||||||
|
satisfied by a photograph, a measurement or comment check by a result. Three
|
||||||
|
tests lock the distinction, including the negative case — remove the photos and
|
||||||
|
it still blocks.
|
||||||
|
|
||||||
|
### What remains
|
||||||
|
|
||||||
|
| From | Item | Why it is still open |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Rev 1 · C-4 | Processor agreement and transfer impact assessment | Documents to be written and signed, not code. |
|
||||||
|
| Rev 1 · m-4 | Retiring the Supabase orchestrator | A deployment decision belonging to the product owner. |
|
||||||
|
| Rev 1 · m-6 | Manual accessibility review | Automated tooling finds malformation, not usability. |
|
||||||
|
| Rev 2 · m-9 | The portal mock | A product decision about what the portal is for. |
|
||||||
|
|
||||||
|
### Re-audit verdict
|
||||||
|
|
||||||
|
The finding that decided this revision — C-5 — is closed at the point where it
|
||||||
|
had to be closed: erasure and the sharing boundary no longer depend on callers
|
||||||
|
sending only the fields the system anticipated.
|
||||||
|
|
||||||
|
The more valuable outcome is M-7. Removing one of two expressions of a rule did
|
||||||
|
not merely prevent future drift; it revealed that the surviving expression had
|
||||||
|
been wrong the whole time, in a way that would have refused almost every case at
|
||||||
|
sync. That is the argument for single-sourcing a rule, made concrete.
|
||||||
|
|
||||||
|
Applying the product's own standard: erasure has moved from `tested` to
|
||||||
|
`validated`. Subject to the four items above, none of which are code, this
|
||||||
|
product would pass supplier assessment for a pilot with customer data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
*ALVA-DOC-0002 · Revision 2 · Internal engineering review*
|
*ALVA-DOC-0002 · Revision 2 · Internal engineering review*
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export function grinda(handelser, metodik) {
|
|||||||
for (const h of av(handelser, "kontroll_utford")) utforda.set(`${h.stegId}/${h.kontrollId}`, h);
|
for (const h of av(handelser, "kontroll_utford")) utforda.set(`${h.stegId}/${h.kontrollId}`, h);
|
||||||
|
|
||||||
const saknade = [];
|
const saknade = [];
|
||||||
const utanFoto = [];
|
const fotokravande = [];
|
||||||
const foton = av(handelser, "foto").length;
|
const foton = av(handelser, "foto").length;
|
||||||
for (const steg of metodik?.steg ?? []) {
|
for (const steg of metodik?.steg ?? []) {
|
||||||
for (const kontroll of steg.kontroller ?? []) {
|
for (const kontroll of steg.kontroller ?? []) {
|
||||||
@@ -114,8 +114,17 @@ export function grinda(handelser, metodik) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (UNDANTAG_MOTIVERAT(h)) continue;
|
if (UNDANTAG_MOTIVERAT(h)) continue;
|
||||||
|
|
||||||
|
// En fotokontroll verifieras av fotot. Regeln krävde tidigare ett
|
||||||
|
// textresultat även här — trots att gränssnittet märker fältet
|
||||||
|
// "Observation (valfritt)". Grinden nekade därmed avslut på nästan
|
||||||
|
// varje riktigt ärende, vilket inte syntes så länge klienten hade
|
||||||
|
// ett eget och mildare villkor (QUALITY-AUDIT-2 · M-7).
|
||||||
|
if (kontroll.krav === "foto") {
|
||||||
|
fotokravande.push(kontroll.text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!(h.resultat ?? "").trim()) saknade.push(`${steg.rubrik} · ${kontroll.text} (utan resultat)`);
|
if (!(h.resultat ?? "").trim()) saknade.push(`${steg.rubrik} · ${kontroll.text} (utan resultat)`);
|
||||||
else if (kontroll.krav === "foto") utanFoto.push(kontroll.text);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
krav(
|
krav(
|
||||||
@@ -124,7 +133,12 @@ export function grinda(handelser, metodik) {
|
|||||||
saknade.length === 0,
|
saknade.length === 0,
|
||||||
saknade.slice(0, 5).join(" · "),
|
saknade.slice(0, 5).join(" · "),
|
||||||
);
|
);
|
||||||
krav("foton", "Foton finns för fotokrävande kontroller", utanFoto.length === 0 || foton >= utanFoto.length);
|
krav(
|
||||||
|
"foton",
|
||||||
|
"Foton finns för fotokrävande kontroller",
|
||||||
|
foton >= fotokravande.length,
|
||||||
|
fotokravande.length ? `${fotokravande.length} kontroller kräver foto, ${foton} foton i loggen.` : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
// ALVA-RULE-200 · Slutsatsen. Ett ärende stängs aldrig utan att
|
// ALVA-RULE-200 · Slutsatsen. Ett ärende stängs aldrig utan att
|
||||||
// teknikern lämnat ett varför — motiveringen som knyter slutsatsen
|
// teknikern lämnat ett varför — motiveringen som knyter slutsatsen
|
||||||
|
|||||||
@@ -67,6 +67,54 @@ export const HÄNDELSESCHEMA = {
|
|||||||
arende_avslutat: {},
|
arende_avslutat: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- Valfria fält (QUALITY-AUDIT-2 · C-5) ------------------------------
|
||||||
|
//
|
||||||
|
// Schemat kontrollerade tidigare bara att de OBLIGATORISKA fälten fanns.
|
||||||
|
// Det itererade schemats nycklar, aldrig händelsens, så vilket okänt fält
|
||||||
|
// som helst passerade och sparades ordagrant.
|
||||||
|
//
|
||||||
|
// Två garantier vilade på motsatsen. Krypto-shreddingen skyddar en fast
|
||||||
|
// lista av fältNAMN: ett registreringsnummer på en vanlig observation
|
||||||
|
// kom aldrig in på listan, krypterades aldrig, och överlevde därför att
|
||||||
|
// nyckeln förstördes — medan raderingskvittot ändå sa att subjektet var
|
||||||
|
// raderat. Och delningsfiltret arbetar på typnivå, inte fältnivå, så
|
||||||
|
// samma fält gick ut i kundens delningslänk.
|
||||||
|
//
|
||||||
|
// Därför är listan nedan uttömmande och avvisningen hård. Ett fält som
|
||||||
|
// inte står här eller bland de obligatoriska finns inte, och en anropare
|
||||||
|
// som tror sig ha sparat något får aldrig veta att det gick bra.
|
||||||
|
export const VALFRIA_FÄLT = {
|
||||||
|
// Bilagefälten ärvs via intersektion med Bilaga i domänmodellen.
|
||||||
|
arbetsorder_skannad: ["bilagaId", "bilagaHash", "dataUrl"],
|
||||||
|
foto: ["bilagaId", "bilagaHash", "dataUrl"],
|
||||||
|
video: ["bilagaId", "bilagaHash", "dataUrl"],
|
||||||
|
matarstallning: ["undantag", "bilagaId", "bilagaHash", "dataUrl"],
|
||||||
|
|
||||||
|
kontroll_utford: ["resultat", "undantag"],
|
||||||
|
// `kalla` bär härkomsten för värden som kommit in via ett
|
||||||
|
// diagnosprotokoll (ALVA-SPEC-020). Fältet måste vara deklarerat:
|
||||||
|
// integrationen fungerade tidigare bara därför att schemat var öppet.
|
||||||
|
observation: ["kalla"],
|
||||||
|
matvarde: ["enhet", "matdonId", "matdonBeteckning", "matdonKalibreradTill", "kalla"],
|
||||||
|
overlamning: ["till"],
|
||||||
|
historik_kontrollerad: ["kommentar"],
|
||||||
|
felorsak: ["motivering", "ytterligareKontroller"],
|
||||||
|
atgardsforslag: ["uppskattadKostnad", "uppskattadTid"],
|
||||||
|
kundbeslut: ["kommentar", "kontaktperson"],
|
||||||
|
atgard_utford: ["delar", "motivering"],
|
||||||
|
slutsats: ["atgardsval", "orsakFastställd"],
|
||||||
|
arende_avslutat: ["signatur"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fält som systemet självt sätter efter valideringen.
|
||||||
|
*
|
||||||
|
* `anvandarId` skrivs av tillPost ur den verifierade token, och
|
||||||
|
* `registrerad_tidpunkt` bevarar klientens klocka vid offline-arbete.
|
||||||
|
* Bägge måste passera när en redan skriven händelse valideras om.
|
||||||
|
*/
|
||||||
|
export const SYSTEMFÄLT = ["typ", "anvandarId", "registrerad_tidpunkt"];
|
||||||
|
|
||||||
// Hypotesen får aldrig anta hög tillförlitlighet — samma regel som
|
// Hypotesen får aldrig anta hög tillförlitlighet — samma regel som
|
||||||
// typsystemet upprätthåller i klienten, upprepad här därför att servern inte
|
// typsystemet upprätthåller i klienten, upprepad här därför att servern inte
|
||||||
// kan lita på att klienten är vår.
|
// kan lita på att klienten är vår.
|
||||||
@@ -101,6 +149,19 @@ export function granskaHändelse(handelse) {
|
|||||||
const fel = fältFel(handelse.typ, nyckel, regel, handelse[nyckel]);
|
const fel = fältFel(handelse.typ, nyckel, regel, handelse[nyckel]);
|
||||||
if (fel) return fel;
|
if (fel) return fel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// C-5: schemat är stängt. Allt som inte är deklarerat avvisas — annars
|
||||||
|
// kan personuppgifter hängas på en vanlig händelse, undgå krypteringen
|
||||||
|
// och följa med ut i delningslänken.
|
||||||
|
const tillåtna = new Set([
|
||||||
|
...SYSTEMFÄLT,
|
||||||
|
...Object.keys(schema),
|
||||||
|
...(VALFRIA_FÄLT[handelse.typ] ?? []),
|
||||||
|
]);
|
||||||
|
const okända = Object.keys(handelse).filter((n) => !tillåtna.has(n));
|
||||||
|
if (okända.length > 0) {
|
||||||
|
return `${handelse.typ}: okända fält avvisas (${okända.join(", ")})`;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -151,7 +151,15 @@ export function verifieraLeverans(kropp, huvud, hemlighet, hmac, timingSafeEqual
|
|||||||
*/
|
*/
|
||||||
export function protokollTillHandelser(protokoll, profil, kalla) {
|
export function protokollTillHandelser(protokoll, profil, kalla) {
|
||||||
const ut = [];
|
const ut = [];
|
||||||
const plocka = (objekt, vag) => String(vag ?? "").split(".").reduce((o, n) => o?.[n], objekt);
|
// Vägen kommer från leverantörsprofilen, alltså utifrån. Uppslaget
|
||||||
|
// begränsas till egna, uppräkningsbara egenskaper: annars når en
|
||||||
|
// profil med `__proto__` eller `constructor.prototype` fram till
|
||||||
|
// prototypkedjan. Läsningen är visserligen ofarlig i sig, men en
|
||||||
|
// extern indata ska inte ha den friheten (QUALITY-AUDIT-2 · m-8).
|
||||||
|
const plocka = (objekt, vag) =>
|
||||||
|
String(vag ?? "")
|
||||||
|
.split(".")
|
||||||
|
.reduce((o, n) => (o !== null && typeof o === "object" && Object.hasOwn(o, n) ? o[n] : undefined), objekt);
|
||||||
|
|
||||||
for (const kod of plocka(protokoll, profil?.felkoder?.vag) ?? []) {
|
for (const kod of plocka(protokoll, profil?.felkoder?.vag) ?? []) {
|
||||||
ut.push({
|
ut.push({
|
||||||
|
|||||||
@@ -1406,12 +1406,32 @@ export function skapaServer() {
|
|||||||
if (!Array.isArray(handelser) || handelser.some((h) => !(h in UTGAENDE))) {
|
if (!Array.isArray(handelser) || handelser.some((h) => !(h in UTGAENDE))) {
|
||||||
return svara(res, 400, { error: "handelser måste vara kända händelsetyper.", kanda: Object.keys(UTGAENDE) });
|
return svara(res, 400, { error: "handelser måste vara kända händelsetyper.", kanda: Object.keys(UTGAENDE) });
|
||||||
}
|
}
|
||||||
|
// QUALITY-AUDIT-2 · M-9. Servern genererade tidigare en
|
||||||
|
// hemlighet, krypterade den, sparade den — och lämnade bara
|
||||||
|
// ut id:t. Den gick därefter inte att nå: GET väljer avsiktligt
|
||||||
|
// inte kolumnen. Mottagaren kunde alltså aldrig verifiera
|
||||||
|
// signaturen, och enda utvägen var att sluta kontrollera den —
|
||||||
|
// vilket gör en signerad kanal osignerad, alltså raka motsatsen
|
||||||
|
// till kontrollens syfte.
|
||||||
|
//
|
||||||
|
// Den genererade hemligheten lämnas nu ut en gång, vid
|
||||||
|
// skapandet, med besked om att den inte visas igen.
|
||||||
|
const egen = typeof hemlighet === "string" && hemlighet.length >= 16;
|
||||||
|
const nyHemlighet = egen ? hemlighet : nyKod(32);
|
||||||
const rad = await pool.query(
|
const rad = await pool.query(
|
||||||
`insert into prenumerationer (organisation_id, namn, url, hemlighet_krypt, handelser)
|
`insert into prenumerationer (organisation_id, namn, url, hemlighet_krypt, handelser)
|
||||||
values ($1, $2, $3, $4, $5) returning id`,
|
values ($1, $2, $3, $4, $5) returning id`,
|
||||||
[anspr.org, namn.trim(), url, kryptera(String(hemlighet ?? nyKod(32))), handelser],
|
[anspr.org, namn.trim(), url, kryptera(nyHemlighet), handelser],
|
||||||
);
|
);
|
||||||
return svara(res, 200, { id: rad.rows[0].id });
|
return svara(res, 200, {
|
||||||
|
id: rad.rows[0].id,
|
||||||
|
...(egen
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
hemlighet: nyHemlighet,
|
||||||
|
anmarkning: "Hemligheten visas bara nu. Spara den i mottagarsystemet innan svaret stängs.",
|
||||||
|
}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1463,23 +1483,75 @@ export function skapaServer() {
|
|||||||
atgard: "Kontrollera att profilens sökvägar matchar leverantörens format.",
|
atgard: "Kontrollera att profilens sökvägar matchar leverantörens format.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// QUALITY-AUDIT-2 · M-8. Tre fel på samma väg, alla med samma
|
||||||
|
// följd: evidens försvann utan att någon fick veta det.
|
||||||
|
//
|
||||||
|
// Tyst validering `if (fel) continue` slängde händelsen och
|
||||||
|
// rapporterade en siffra. Ett instrument som
|
||||||
|
// laddade upp tolv avläsningar och fick fem
|
||||||
|
// avvisade fick 200 OK och kunde inte ta reda
|
||||||
|
// på vilka fem eller varför.
|
||||||
|
//
|
||||||
|
// Id-krock `prot-${Date.now()}-${i}` är unikt bara
|
||||||
|
// inom ett anrop i en millisekund. Två
|
||||||
|
// samtidiga uppladdningar gav samma id och
|
||||||
|
// `on conflict do nothing` slukade den andra
|
||||||
|
// — medan räknaren ändå räknade den.
|
||||||
|
//
|
||||||
|
// Ingen transaktion Ett fel vid rad 8 av 12 lämnade åtta rader
|
||||||
|
// skrivna och ärendet med ett halvinläst
|
||||||
|
// protokoll utan markering.
|
||||||
|
//
|
||||||
|
// Id:t härleds nu ur innehållet, vilket också gör en omtagning
|
||||||
|
// av samma protokoll idempotent i stället för dubblerande.
|
||||||
const nyckel = await personnyckel(anspr.org, protokollVag[1]);
|
const nyckel = await personnyckel(anspr.org, protokollVag[1]);
|
||||||
let skrivna = 0;
|
const avvisade = [];
|
||||||
|
const poster = [];
|
||||||
for (const [i, h] of handelser.entries()) {
|
for (const [i, h] of handelser.entries()) {
|
||||||
const { post, fel } = tillPost(
|
const avtryck = crypto
|
||||||
{ id: `prot-${Date.now()}-${i}`, handelse: h },
|
.createHash("sha256")
|
||||||
anspr,
|
.update(`${protokollVag[1]} | ||||||