Worked examples · 08 of 09
Regional IT framework
A region's five-year IT-modernisation framework: consultant hours in three roles from capped, index-adjusted pools; hardware by Peppol despatch advice; a fixed-price migration paid on a betalplan with 10% retention; monthly invoicing; and a proforma invoice judged before the binding one - all under one multi-year spend ceiling.
- SIZE
- very complex, five-year
- PROFILE
- mk-se-public
- STRESSES
- Everything at once, across five years: three index-adjusted consultant-hour pools, goods by despatch advice, milestone retention gated by an appointed inspector, monthly invoicing, nested budget ceilings, and a proforma the machine judges before the binding invoice exists.
A region signs a five-year multi-service IT-modernisation ramavtal: consultant hours in three roles drawn from capped pools under an AKI-indexed rate card, hardware and licenses arriving by Peppol despatch advice into a goods register, a fixed-price platform-migration delprojekt paid by a signed betalplan with 10% retention, monthly invoicing at a declared cadence, and - new to the corpus - a proforma invoice the machine judges before the binding one exists. One spend ceiling over everything, per-year caps under it, per-service caps beside them, two extension options exercised by active renewal, an independent granskare deciding when milestones are done, and an index revision landing every January. The school-renovation example proved complexity composes at project scale; this one proves it composes across years - genesis to expiry is roughly sixty months and several hundred receipts, and nothing in between needs a wall clock. It mostly holds; five things creak.
Profile: mk-se-public. Prose backbone: the awarded framework agreement plus its administrative provisions as the hashed Ricardian bundle; confidentiality, force majeure, the data-processing agreement, and exit obligations live in the soft-term layer. One honesty note carried as a soft term: LOU’s four-year default for framework agreements is exceeded here (3 years + 2 x 12 months = 5), on documented särskilda skäl - the migration horizon of the delprojekt. The machine does not check LOU; the profile’s linter flags the duration so the särskilda-skäl clause cannot be forgotten.
The contract
mechanical-contract v0
id: mk-se-2027-002400
title: Ramavtal IT-modernisering Region Söderland 2027-2031
prose: ramavtal-it-modernisering.pdf sha256:b7e2... # kontrakt + AF; femårig löptid
annex: [ "§18 Sekretess", "§21 Force majeure", # på dokumenterade särskilda
"§23 Personuppgiftsbiträde (GDPR art 28)", # skäl (LOU 7:2) - soft terms,
"§25 Exit och överlämning" ] # hashed, prose governs
parties:
beställare: org 232100-7719 "Region Söderland" sign eIDAS-qualified-seal
leverantör: org 556812-4409 "Nordkraft Digital AB" sign eIDAS-qualified-seal
granskare: APPOINTED, not signed at genesis # party slot: the independent
slot: certified per art:oberoende-it-granskare # inspector gating milestone
appointed-by: both, via granskare.appoint port # money - constraints bound now,
# the person bound later
validity:
from 2027-01-01 to 2029-12-31
extend: 2 x 12mo, notice 9mo, mode: active-renewal # full horizon 2031-12-31.
# active-renewal: silence =
# expiry; the negative option
# is impossible (§2)
money:
currency SEK
vat 25% on services and goods
rounding per-line half-up 0.01 # öre are the unit; no floats
# exist anywhere in this file
# ── DECLARED STATE ─────────────────────────────────────────────
state:
# one ceiling over everything, service sub-caps under it
pool takvolym capacity 120_000_000 SEK
stages available|reserved|retained|disbursed # declared stages:
# 'retained' for the 10%
pool budget.timmar capacity 70_000_000 SEK sub-cap of takvolym
pool budget.material capacity 28_000_000 SEK sub-cap of takvolym
pool budget.milstolpar capacity 22_000_000 SEK sub-cap of takvolym
pool budget.år keyed-by year within validity # keyed pool; an
capacity 30_000_000 SEK each # extension adds
# year-cells
# people: role pools over five years (§8.5 human flavor)
pool timmar.senior capacity 24_000 h
pool timmar.specialist capacity 8_000 h
pool timmar.junior capacity 16_000 h
register avrop shape peppol.order-line
register utförda-timmar shape { avrop-ref: id, roll: code,
timmar: quantity<h>, period: month }
register leveranser shape despatch-line # goods in the house, not yet
# invoiced: the fact every
# material line must stand on
register milstolpar from betalplan # rows -> event-dues (§3.4)
register fakturor # accepted, not yet paid
# ── EXTERNAL FACTS (signed fixings, never lookups) ─────────────
fixings:
index aki series SCB.AKI.J62 base 2026-09 signed-by scb-ingest revise yearly 01-01
# labour-cost index for J62 (IT services); ingest-attested per §5.5
# until SCB seals its series - trust tier declared, not assumed
# ── ARTIFACTS: every table this contract lives by, hashed ──────
artifacts:
timprislista art:nordkraft-timpriser-2027 pin v1 sha256:d4a9...
class contract-bilaga
update: proposed-by leverantör, approved-by beställare,
at-most 1 per year, window January,
bounds within index-clause cap # an illegal update
# cannot become v2 (§5.2)
prisbilaga art:nordkraft-materialpriser track latest-approved sha256:71c0...
class contract-bilaga
update: proposed-by leverantör, approved-by beställare,
at-most 2 per year, window January|July,
bounds within 5% per article # negotiated moves, governed;
# NOT indexed
betalplan art:betalplan-plattformsmigrering pin v1 sha256:1f8e...
class contract-bilaga # M1..M5, 18_000_000 SEK total;
# each row -> an event-due (§3.4)
kalender art:se-working-days track latest sha256:88a0...
attestordning art:soderland-attestordning track latest-approved
class party-maintained-register # buyer-maintained (§5.6);
# counterparty receipted on change
rates: from timprislista # variables resolve against the pinned version
senior 1_150 SEK/h indexed-by aki cap 3%/yr # in force at each event's
specialist 1_390 SEK/h indexed-by aki cap 3%/yr # effective date (§5.1)
junior 795 SEK/h indexed-by aki cap 3%/yr
# material prices are NOT indexed: they move only by governed prisbilaga
# versions. One revision mechanism per line item, never both.
# ── PORTS: the capability manifest ─────────────────────────────
ports:
in avrop.order from beställare payload peppol.order # call-off:
# hours or goods
in timesheet.submit from leverantör payload sfti.timesheet
in material.delivery from leverantör payload peppol.despatch-advice
in milestone.claim from leverantör payload signed-claim
in granskare.appoint from both payload signed-appointment # fills the slot
in leveranskontroll from granskare payload signed-inspection
in proforma.submit from leverantör payload en16931-invoice type-code 325
judges invoice.submit, moves nothing # BT-3 = 325
# (proforma): the
# judged, non-
# binding twin
in invoice.submit from leverantör payload peppol.bis-billing-3 # BT-3 = 380
in payment.confirm from beställare payload iso20022.pain.002
in approval.grant from beställare.attestant payload signed-decision
in extension.confirm from beställare payload signed-decision # active renewal
in notice.send from both payload signed-text
out receipt.emit to submitter always
out notice.emit to both on rule-alerts
out payment.instruct CLOSED # referee mode: the machine judges the
# region's ERP-run payments, never moves money
out invoice.issue CLOSED # invoicing OUT is not this contract's business
# fordran.assign (factoring): NO PORT -> the receivable is not assignable
# under this contract; assignment requires an amendment (transform, §6.4)
# ue.order (subcontracting): NO PORT -> no back-to-back child under THIS
# contract. Absence is prohibition (§1).
# ── CADENCE: the contract's clock-free rhythm (§3.2) ───────────
cadence:
proforma.submit at-most 4 per month # its own bucket - a proforma
# NEVER consumes the invoice cadence
invoice.submit at-most 1 per month # faktureringsintervall
invoice.submit not-before matching delivery | timesheet | milestone-due
# no advance billing, any service
payment.confirm due within 30 days of invoice accept # betalningsvillkor; late
# -> breach, dröjsmålsränta per
# räntelagen, computed from the log
avrop.order at-most 30 per month per förvaltning # anti-splitting guard:
# attest thresholds cannot be
# snuck under (§3.2)
timesheet.submit at-most 1 per month per avrop
extension.confirm within notice-window(validity.extend) # 9mo before expiry ..
# expiry; outside = rejected
# ── APPROVAL: straight-through vs someone-in-the-loop (§4.1) ───
approval: invoice.submit
if total <= 100_000 SEK -> straight-through
if total <= 2_000_000 SEK -> require 1 of beställare.attestant
otherwise -> require 2 of beställare.attestant
# attestant resolves against art:soderland-attestordning at event date;
# tvåhandsprincipen above 2M. DMN hit-policy UNIQUE: rows tile, no overlap.
timeout: approval pending > 10 working-days -> escalate level 1
approval: milestone.claim
require 1 of granskare # leveranskontroll godkänd -
# third-party gate on every
# milestone krona
timeout: leveranskontroll pending > 15 working-days -> escalate level 1
# ── RULES: if / which, nothing else (§9) ───────────────────────
rule on avrop.order: # payload = Peppol order
which payload.kind is
tjänst:
if payload.roll not in rates -> reject "roll ej på timprislistan"
if timmar.[payload.roll] remaining < payload.timmar -> reject "timpott uttömd"
reserve timmar.[payload.roll] amount payload.timmar for payload.förvaltning
reserve budget.timmar via takvolym, budget.år[payload.year]
amount payload.timmar × rates.[payload.roll] # ONE reservation,
vara: # THREE ceilings debited
if payload.artikel not in prisbilaga -> reject "artikel ej i prisbilaga"
reserve budget.material via takvolym, budget.år[payload.year]
amount payload.qty × prisbilaga[payload.artikel]
otherwise: -> reject "okänd avropstyp"
record avrop
accept
rule on timesheet.submit:
check reservation matches payload.avrop-ref else reject "inga timmar utan avrop"
if payload.timmar > reservation.remaining -> reject "överskrider avropet"
record utförda-timmar { roll: payload.roll, timmar: payload.timmar,
period: payload.period }
accept
rule on material.delivery: # payload = Peppol despatch advice
check lines reference accepted avrop else reject "leverans utan avrop"
each line in payload.lines:
if line.qty > avrop[line.order-ref] remaining -> reject "mer än avropat"
record leveranser # goods register grows; nothing
accept # is billable that isn't in here
rule on proforma.submit: # the judged twin.
judge as invoice.submit # the ENTIRE invoice gauntlet
# runs verbatim - BT-13, line
# prices, index math, delivery
# backing, all three ceilings -
effects none # but accepted or rejected,
accept advisory # NOTHING moves: no draws, no
# register rows, no invoice
# cadence consumed. The receipt
# chains like any other; the
# binding invoice may cite it.
rule on invoice.submit: # payload = EN 16931 invoice
if payload.BT-3 != 380 -> reject "fel dokumenttyp"
if payload.BT-13 not references this.id -> reject "ingen avtalsreferens"
each line in payload.lines: # bounded map, the only loop
which line.item is
timmar.senior | timmar.specialist | timmar.junior:
let expected := rates.[line.item] at line.period # index math: base ×
# aki(period)/aki(2026-09),
# capped at 3%/yr compounded
if line.BT-146 != expected # BT-146: item net price
-> reject diff { billed: line.BT-146, contractual: expected,
clause: rates + index aki,
excess: (line.BT-146 - expected) × line.qty }
if line.qty > utförda-timmar unbilled for line.item
-> reject "timmar utan tidrapport"
artikel:
if line.BT-146 != prisbilaga[line.artikel] at delivery-date
-> reject diff { billed: line.BT-146, # priced at the prisbilaga
contractual: prisbilaga[line.artikel], # version in force
clause: prisbilaga } # at DELIVERY (§5.1)
if line.qty > leveranser unbilled for line.artikel
-> reject "ingen leverans bakom raden" # the goods covenant
milstolpe:
check matching open due from milestone.claim
else reject "milstolpe ej godkänd" # the 90% event-due is the
# only thing billable here
otherwise: -> reject "rad utanför avtalet"
if invoice.total would breach takvolym | budget.år[year] | matching sub-cap
-> reject "takbrott" citing the binding ceiling # three ceilings, one guard;
# the receipt names WHICH one
draw pools per line; record fakturor
accept # -> the approval table picks
# the leg: STP, 1, or 2
rule on milestone.claim:
if no matching row in betalplan -> reject "ej i betalplan"
expect leveranskontroll godkänd for payload.milstolpe # §7.5 call-out shape: the
# claim parks PENDING (a fold
# state, not an authored verb),
# clock-blind, resumes on inspection
reserve budget.milstolpar via takvolym, budget.år[year] amount row.belopp
row.belopp × 90% -> due payment within 30 days # billed via invoice.submit
row.belopp × 10% -> retained # innehållet belopp: comes
accept # home at slutmilstolpen
rule on leveranskontroll: # the granskare speaks
which payload.result is
godkänd:
if payload.milstolpe is slutmilstolpe: # fork (§3.7): one event
release retained -> due payment within 30 days # releases retention AND
open garantitid 24 months # opens garanti AND opens
open exit-åtagande per annex §25 # the exit obligations
godkänd-med-anmärkning:
due åtgärda anmärkningar within 30 days
underkänd:
due ny leveranskontroll; claim stays parked
accept
rule on extension.confirm: # active renewal exercised
check within notice-window(validity.extend) else reject "utanför fönster"
extend validity +12 months # this does more than bump a
add budget.år cell for the new year # date - it mints a fresh 30M
accept # year-cell
rule on payment.confirm:
if payload.ref not in fakturor -> reject "okänd faktura"
record fakturor[payload.ref] = paid # late confirm = breach; the
accept # interest claim computes from
# the log's due date (§3.4)
# ── ALERTS: assertions over the fold, not dashboard config ─────
alerts:
when takvolym consumed > 80% notify both "takvolym {level}%"
when budget.år[current] consumed > 90% notify beställare "årstak nära"
when timmar.senior consumed > 80% notify both "seniorpott {level}%"
when today in notice-window(validity.extend) notify beställare "förlängningsbeslut krävs"
when fixing aki stale > 13mo notify operator "indexserie tyst"
# ── ESCALATION: throw / catch / finally (§4.2) ─────────────────
escalation:
on pool-exhausted | obligation-breached | rejected(3 times, 30d) | approval-timeout:
level 1 avtalsansvariga both parties resolve-within 10 working-days
level 2 styrgrupp; suspend avrop.order 20 working-days
level 3 mediation per prose draws frozen, receipts keep flowing
level 4 termination path per prose
finally: # survival, on ANY exit - incl. quiet
sekretess per §18 lives 5y past termination # expiry 2031-12-31
garantiåtagande 24mo per delivery / milestone
exit-åtagande per §25 datamigrering + dokumentation within 90 days
personuppgiftsbiträde per §23 until data returned or erased, receipted
# ── EXECUTION CONTRACT ─────────────────────────────────────────
execution:
idempotency: submission-id (uuid v7); duplicate -> original receipt, state unchanged
ordering: single log per contract; receipt N embeds hash(receipt N-1)
replay: state is a pure fold over the receipt chain - all ~sixty months of it
determinism: no clock, no floats, no I/O, no unbounded loops - lint-provable (§1).
Every "date" above is an event timestamp already in the log; the
January index revision, the 9-month notice window, the 30-day due -
all event-time arithmetic (§3.2, §3.4).
The happy path, as receipts
Sixty months compress to the load-bearing submissions; ... marks the monthly cycle repeating. Every row below is a signed, hash-chained envelope; replaying the chain reproduces the final state bit-for-bit.
| # | Event | Verdict | State after |
|---|---|---|---|
| r‑0 | genesis (both seals) | ACCEPTED | pools full, granskare slot EMPTY, milstolpar loaded from betalplan |
| r‑1 | granskare.appoint (both parties) | ACCEPTED | slot bound: Certezza Granskning AB, per competence register |
| r‑2 | avrop.order - tjänst: 1 200 h senior, förvaltning Vård-IT | ACCEPTED | timmar.senior 1 200 h reserved; 1 380 000 SEK reserved in budget.timmar + takvolym + år[2027] |
| r‑3 | avrop.order - vara: 40 st accesspunkter | ACCEPTED | 388 000 SEK reserved in budget.material + takvolym + år[2027] |
| r‑4 | timesheet.submit (2027-02, 130 h senior) | ACCEPTED | utförda-timmar +130 h unbilled |
| r‑5 | material.delivery (despatch advice, 40 st) | ACCEPTED | leveranser +40 st unbilled |
| r‑6 | proforma.submit (mars: 130 h + 40 st) | REJECTED | diff: artikel billed 10 350,00, contractual 9 700,00, clause prisbilaga. Nothing moved, no cadence consumed - the supplier fixes the line without having submitted a defective invoice |
| r‑7 | proforma.submit (corrected) | ACCEPTED (advisory) | still nothing moved; receipt r‑7 chains and is citable |
| r‑8 | invoice.submit (binding twin, BT-3 380: 149 500 + 388 000 = 537 500 net) | PENDING | ≤ 2M -> awaiting 1 of beställare.attestant; pools untouched until the leg completes |
| r‑9 | approval.grant (attestant, per attestordning) | ACCEPTED | fakturor +1; draws land; due: payment ≤ 30 days |
| r‑10 | payment.confirm (pain.002) | ACCEPTED | faktura paid; disbursed stage grows |
| … | monthly cycle repeats: timesheet -> delivery -> proforma (optional) -> invoice -> payment | ||
| r‑41 | fixing revision: aki 2028-01 published (ingest-attested) | ACCEPTED | rates for 2028 periods now resolve indexed |
| r‑42 | invoice.submit (jan 2028: senior @ 1 184,50) | ACCEPTED (STP, < 100k) | rule trace shows the math: raw aki-ratio 1,043 > cap -> 1 150 × 1,03 = 1 184,50/h - the cap binds |
| r‑55 | prisbilaga update proposed (July window, +3,8% on switchar) | ACCEPTED | prisbilaga v2 in force; all later deliveries price against it |
| r‑56 | prisbilaga update proposed (October) | REJECTED | outside window - never becomes a version (§5.2); the quiet-raise door does not open |
| r‑70 | milestone.claim M3 “Plattform i drift” (4 000 000 SEK) | PENDING | parked, awaiting leveranskontroll |
| r‑71 | leveranskontroll M3: godkänd | ACCEPTED | claim resumes: 3 600 000 due ≤ 30d; 400 000 -> retained |
| r‑72 | invoice.submit (M3 90%, 3 600 000) | PENDING -> ACCEPTED | > 2M -> 2 attestants (tvåhandsprincipen); then due payment ≤ 30d |
| r‑102 | extension.confirm #1 (2029-05, inside the 9-month window) | ACCEPTED | validity -> 2030-12-31; budget.år[2030] minted at 30M |
| r‑160 | extension.confirm #2 (2030-04) | ACCEPTED | validity -> 2031-12-31; år[2031] minted; horizon complete |
| r‑201 | leveranskontroll slutmilstolpe M5: godkänd | ACCEPTED | fork: all retained (1 800 000 across M1..M5) released -> due ≤ 30d; garantitid 24mo opens; exit-åtagande opens |
| r‑202 | (2031-12-31 passes - no event needed: expiry is event-math, §3.4) | - | contract expires; finally survives: sekretess, garanti, exit, PUB-avtal keep their own dues |
A dispute anywhere in those five years is a receipt, not an archaeology project: “why was the March 2027 invoice wrong” is r‑6’s eight-line diff; “who approved the 3,6M milestone payment” is r‑72’s two attestant signatures; “when did the switch price change and was it legal” is r‑55 versus r‑56.
What this example stresses
| Edge | Spec section |
|---|---|
| Five-year validity with active-renewal extensions and notice windows, all event-time | §2, §3.2, §3.4 |
| Proforma: the full invoice gauntlet run as a receipted, state-free judgment | §2, §3.3 (pre-validation promoted on-log) |
| Three service lines (hours, goods, milestones) under one port manifest | §1, §8.5 |
| One draw debiting sub-cap + year-cap + spend ceiling at once | §3.1 (Petri: one transition, several places), §3.4 |
| Keyed per-year budget pools; an extension minting new cells | §3.1, §6.4 |
| Goods covenant: no invoice line without a despatch-advice line behind it | §2, §10 (Peppol post-award) |
| Betalplan rows as event-dues; 90/10 retention; release on inspection | §3.4 |
| Third-party granskare in an appointed party slot gating milestone money | §4.1 |
| Monthly invoice cadence + not-before + anti-splitting + 30-day due, over 60 months | §3.2 |
| Two price-revision mechanisms: AKI fixing on hours, governed prisbilaga on goods | §5.1-5.2, §2 |
| Straight-through / 1-attestant / 2-attestant legs from a checked table | §4.1 |
| Escalation ladder + finally-obligations outliving a five-year natural expiry | §4.2 |
| Deliberately absent ports: factoring and subcontracting prohibited by silence | §1, §7.1 |
| Index math with a binding cap, replayable in the rule trace | §2, §5 |
Questions this example forced
- Proforma wants a port kind, not a rule trick. Pre-validation (§3.3) is stateless and off-log: run the file, get an answer, nothing recorded. A proforma is legally and commercially more than that - the supplier wants a citable, signed judgment (“the machine blessed this exact payload on this date”) before committing a binding invoice, and EN 16931 already has the document (BT-3 type code 325). Honest gap: Peppol BIS Billing 3 restricts BT-3 to a subset that excludes 325, so the proforma has the semantic model but no rail - it travels bilaterally or via the pre-validation endpoint. The
judge as invoice.submit / effects nonestrawman works but smuggles a new semantics into rule syntax. Proposed: a declared port kind -probe- whose verdicts carry full rule traces, whose receipts chain normally, and whose ACCEPTED is defined as moving no state and consuming no cadence except its own. The binding submission may cite its probe receipt hash. A probe verdict can go stale (state moves between probe and binding twin), so a probe receipt must embedstate-beforelike every envelope already does - staleness detectable by hash, not by promise. - One krona, three ceilings: pools want hierarchy. A consultant-hour reservation here debits
budget.timmarANDbudget.år[2027]ANDtakvolym. §3.4’s reserve names one pool; theviastrawman is doing something the spec never defined. The Petri math is already bought - a transition consuming from several places is the ordinary case (§3.1) - but the grammar and the conservation story are not: are sub-caps a partition of the parent (sum of children equals parent, linted) or deliberate over-allocation (sum exceeds parent, flexibility)? Proposed: declared pool hierarchy with a per-hierarchy policypartition | over-allocated, draws always debiting the full path, and the linter extending pool-safety to “no path exceeds any ancestor”. Orthogonal axes (service × year) mean a draw sits under two independent hierarchies - exactly encumbrance accounting’s fund / appropriation / allotment structure, sixty years old. - Exercising an extension mints state.
extend validity +12 monthslooked like a date bump until the keyed year-pool made it honest: the extension createsbudget.år[2030], 30M of fresh capacity that did not exist before. Anything that changes capacity smells like a transform (a signed, conservation-checked delta) - but the extension was anticipated and priced at genesis: at-most 2 extensions, 12mo each, notice window, all declared. Proposed: anticipated change = declared headroom + an ordinary event within it; the conservation lint treats genesis-declared future capacity as latent (counted in the total from day one, activated by the event). The spend ceiling then means the ceiling over the full five-year horizon, extensions included - which is also what LOU’s takvolym case law demands the procurement documents state. - Two legal ways for a price to move; the linter must forbid a third. Hours move by formula against a published fixing (AKI ratio, capped - deterministic, nobody decides anything). Material moves by governed artifact update (proposed, approved, windowed, bounded - somebody decides, under rules). Both are in the spec; what it never says is that they are exclusive per line item. A rate that is BOTH indexed AND updatable is double-indexing waiting to happen. Proposed: every priced item declares exactly one revision mechanism (
indexed-byXOR an owning artifact with update governance), and updating an indexed item’s base rate is legal only as a transform. The principle: formulaic, source-published adjustment = fixing; negotiated adjustment = governed update; silent adjustment = structurally impossible. - When does the region own the goods? The despatch advice records arrival; the invoice bills it; payment settles it - and the ownership verbs demand somebody declare where title moves. Retention of title (
moves title when payment.confirm) is standard drafting, but at this contract’s cadence one monthly invoice covers many despatch lines and pays once - so “when paid” is per-line only if the goods register tracks title per line. Public-sector practice mostly takes title at approved delivery instead. Proposed: the profile default for public goods is title on accepted delivery; äganderättsförbehåll must be declared explicitly, and where it is, the linter requires the goods register’s shape to carry a title stage - the creak visible at authoring time instead of at insolvency.
Status: example, not roadmap. What it proves: the five-year, multi-service, everything-at-once framework agreement composes from existing primitives plus five vocabulary creaks, and not one of the creaks touches determinism - probe ports, pool hierarchies, latent capacity, revision exclusivity, and title stages are all declarations, never computation. Sixty months of contract life, one fold, no clock.
Tests
These behaviour tests exercise the contract above. Because the contract is executable it is testable: the same machine that evaluates it runs the tests - Gherkin to read, JavaScript to run.
Feature: Regional IT framework
Scenario: A proforma flags a mispriced line and moves nothing
When leverantör submits proforma.submit with an over-priced material line
Then the verdict is REJECTED
And nothing in state changes and no invoice cadence is consumed
And the trace cites "prisbilaga"
Scenario: The corrected binding invoice is then accepted
Given the price is corrected to the prisbilaga version in force
When leverantör submits invoice.submit
Then the verdict is ACCEPTED or PENDING (per the approval leg)
Scenario: A goods line with no delivery behind it is refused
When leverantör submits invoice.submit for goods never despatched
Then the verdict is REJECTED
And the trace mentions "ingen leverans bakom raden"
Scenario: Consultant hours above the indexed rate are rejected
Given aki(2028-03) makes the capped senior rate 1184.50 SEK/h
When leverantör submits invoice.submit billing senior at 1250.00
Then the verdict is REJECTED
And the receipt cites "rates + index aki"
Scenario: A milestone not in the betalplan cannot be claimed
When leverantör submits milestone.claim for "M9"
Then the verdict is REJECTED
And the trace mentions "ej i betalplan"
Scenario: An extension confirmed outside its 9-month window is refused
When beställare submits extension.confirm 3 months before expiry
Then the verdict is REJECTED
And the trace mentions "utanför fönster"
Scenario: A second invoice in the same month breaks the cadence
Given one invoice this month is already accepted
When leverantör submits a second invoice.submit the same month
Then the verdict is REJECTED
import { test, expect } from "bun:test"
import { createContractMachine } from "@mechanical-contract/test"
test("a goods line with no delivery behind it is refused", () => {
const mk = createContractMachine("mk-se-2027-002400")
const r = mk.as("leverantör").submit("invoice.submit", {
"BT-3": "380",
"BT-13": "mk-se-2027-002400",
lines: [{ item: "artikel", artikel: "accesspunkt", qty: "40", "BT-146": "9700.00" }],
})
expect(r.rejected).toBe(true)
expect(r.trace).toContain("ingen leverans bakom raden")
})
test("a milestone not in the betalplan cannot be claimed", () => {
const mk = createContractMachine("mk-se-2027-002400")
const r = mk.as("leverantör").submit("milestone.claim", { milstolpe: "M9" })
expect(r.rejected).toBe(true)
expect(r.trace).toContain("ej i betalplan")
})
test("an extension outside its 9-month window is refused", () => {
const mk = createContractMachine("mk-se-2027-002400")
const r = mk.as("beställare").submit("extension.confirm", {}, { at: "2029-11-01T00:00:00Z" })
expect(r.rejected).toBe(true)
expect(r.trace).toContain("utanför fönster")
})
test("a proforma judges without moving state, then the fixed invoice lands", () => {
const mk = createContractMachine("mk-se-2027-002400")
const proforma = mk.as("leverantör").submit("proforma.submit", { "BT-3": "325", overpriced: true })
expect(proforma.movedState).toBe(false) // advisory: no draws, no register rows, no cadence
})