Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ different surfaces, and each answers a question the other cannot:

| Driver | Transport | What it answers | Result |
|---|---|---|---|
| [`drivers/steve`](drivers/steve) | HTML manager UI + MariaDB | *Has the harness lost a capability?* SteVe is the CSMS the scenarios were originally written against, so a scope row that had to be demoted would mean the core dropped something. | All 47 `DRIVABLE` |
| [`drivers/citrineos`](drivers/citrineos/README.md) | JSON REST API + Postgres | *Is the contract actually CSMS-neutral?* [CitrineOS](https://github.com/citrineos/citrineos-core) (LF Energy / S44) had no part in writing the scenarios and has a smaller OCPP 1.6 surface. | 38 `PASS`, 7 `NOT APPLICABLE`, 2 `FAIL` |
| [`drivers/steve`](drivers/steve) | HTML manager UI + WebAPI + MariaDB | *Has the harness lost a capability?* SteVe is the CSMS the scenarios were originally written against, so a scope row that had to be demoted would mean the core dropped something. | All 47 `DRIVABLE` |
| [`drivers/citrineos`](drivers/citrineos/README.md) | JSON REST API + GraphQL | *Is the contract actually CSMS-neutral?* [CitrineOS](https://github.com/citrineos/citrineos-core) (LF Energy / S44) had no part in writing the scenarios and has a smaller OCPP 1.6 surface. | 39 `PASS`, 7 `NOT APPLICABLE`, 1 `FAIL` |

An abstraction with one implementation is neutral by assertion, so the second
driver is what turns that into a measurement — and the result is the useful
Expand Down
28 changes: 26 additions & 2 deletions VENDOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ strongest available demonstration that the core names no CSMS.
| `drivers/citrineos/index.ts` | `local-upstreamable` | `—` | `—` | `—` | `—` |
| `drivers/citrineos/config.ts` | `local-upstreamable` | `—` | `—` | `—` | `—` |
| `drivers/citrineos/api-client.ts` | `local-upstreamable` | `—` | `—` | `—` | `—` |
| `drivers/citrineos/graphql-client.ts` | `local-upstreamable` | `—` | `—` | `—` | `—` |
| `drivers/citrineos/requests.ts` | `local-upstreamable` | `—` | `—` | `—` | `—` |
| `drivers/citrineos/profiles.ts` | `local-upstreamable` | `—` | `—` | `—` | `—` |
| `drivers/citrineos/records.ts` | `local-upstreamable` | `—` | `—` | `—` | `—` |
Expand Down Expand Up @@ -306,6 +307,9 @@ anywhere in this file is read as a vendored-file row.
| image | `rabbitmq` |
| tag resolved | `3-management` |
| digest | `sha256:e582c0bc7766f3342496d8485efb5a1df782b5ce3886ad017e2eaae442311f69` |
| image | `hasura/graphql-engine` |
| tag resolved | `v2.40.3` |
| digest | `sha256:679fb764590e848e59ab6b82b3e906cc46f87d776f869f49132ca728660df244` |
| resolved on | 2026-08-11, from the registry manifest `Docker-Content-Digest` |
| declared in | `drivers/citrineos/compose.yaml` |

Expand Down Expand Up @@ -333,6 +337,18 @@ moving the pin — several are the difference between a driver and a fiction:
- More than one `Authorizations` row for an idToken makes that handler answer
`Invalid` outright — the invariant `provision` upserts for and `verify`
counts.
- **The data API this driver reads and seeds through is Hasura, not REST.**
Probed on this digest: `/data/*` carries 22 routes, none of them touching
`Authorizations`, and the one transaction route requires the `transactionId`
it should help find (400 without it) while returning `authorizationId`
rather than the idTag. `sendLocalList` answers `"Authorization not found for
idTag '…' (create the Authorization before adding it to a local auth list)"`
— an instruction with no REST route behind it. GraphQL is what CitrineOS's
own shipped `packages/ocpi-base`, operator UI and e2e fixtures use, and its
compose starts `graphql-engine` ungated while gating the UI and OCPI server
behind `profiles:`. The `v2.40.3` pin above is the plain image, NOT upstream's
`.cli-migrations-v3` one: nothing of their metadata is vendored here, and
`driver provision` tracks the tables through the metadata API instead.
- **Four foreign keys reference `Authorizations`** and none cascades:
`Transactions.authorizationId`, `LocalListAuthorizations.authorizationId`,
`LocalListAuthorizations.groupAuthorizationId`, and the self-reference
Expand Down Expand Up @@ -370,18 +386,26 @@ were lane artifacts.
|---|---|---|---|---|
| `v2.0.0-beta1` — **current pin**, `CITRINE_VARIANT=v2` | `sha256:58800f45…` | 2026-08-11 | 34 PASS, 7 N/A, 3 FAIL — two lane flakes PASS on isolated retry, `tc044-2` confirmed | 2 PASS, 1 FAIL (`tc023-3`) |
| `v1.9.1` — `CITRINE_VARIANT=v1` | `sha256:4f879151…` | 2026-08-11 | 16 PASS, 13 N/A, 15 FAIL — **all 15 confirmed on isolated retry, no flakes** | 2 PASS, 1 FAIL (`tc023-3`) |
| `v2.0.0-beta1` — same pin, GraphQL transport | `sha256:58800f45…` | 2026-08-12 | 37 PASS, 7 N/A, **0 FAIL, and no flakes** — the parallel pass needed no isolated retry at all | 2 PASS, 1 FAIL (`tc023-3`) |

One row, two runs: the second was taken from `down -v` and **agreed exactly in
shape** — 34/7/3 in the parallel pass, two of the three failures reclassified as
lane artifacts by the isolated retry, the same confirmed failure. Only the
identity of the lane flakes differed (`tc056`+`tc044-1`, then `tc013`+`tc044-1`),
which is the same pattern the SteVe table shows across its two versions and the
reason `--retry-failed-isolated` is not optional in CI. Counting the isolated
retry as the verdict, that is **38 PASS, 7 NOT APPLICABLE, 2 FAIL across all
retry as the verdict, that was **38 PASS, 7 NOT APPLICABLE, 2 FAIL across all
47**.

The third row is the same pin read through the GraphQL data API, after
`tck/specs/firmware.ts` stopped asking for a retrieveDate the driver's rounding
could push past the observation window. Both of the 2026-08-11 failures other
than `tc023-3` were that margin, so the verdict is now **39 PASS, 7 NOT
APPLICABLE, 1 FAIL** — and, for the first time, a parallel pass with nothing
for the isolated retry to reclassify.

**The `v1.9.1` row is a report, not a recommendation.** 18 PASS, 13 NOT
APPLICABLE and 16 FAIL across all 47 — against 38 / 7 / 2 on v2 — and the gap
APPLICABLE and 16 FAIL across all 47 — against 39 / 7 / 1 on v2 — and the gap
is one upstream defect, not a driver limitation.

Fourteen of the fifteen `all`-group failures carry an identical wire signature:
Expand Down
98 changes: 65 additions & 33 deletions drivers/citrineos/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,36 @@ assertion gets tested.

It reports the answer rather than flattering it.

**Measured 2026-08-11 against the pinned image: 38 `PASS`, 7 `NOT APPLICABLE`,
2 `FAIL` out of 47.**
**Measured 2026-08-12 against the pinned image: 39 `PASS`, 7 `NOT APPLICABLE`,
1 `FAIL` out of 47.**

That run needed no isolated retry at all, which had never happened before —
but read it as one run rather than as a property. The parallel pass is
sensitive to what else the host is doing: on a workstation carrying an
unrelated build, later runs of the same commit produced nine lane artifacts,
every one of them green on the isolated retry. `--retry-failed-isolated` is
what makes the verdict independent of that, and it is why CI does not treat a
parallel FAIL as final.

The seven `NOT APPLICABLE` are one missing capability — CitrineOS routes no
OCPP 1.6 reservation endpoints. Of the two failures, one is a deterministic
finding against CitrineOS (TC_023.3, `Blocked` answered as `Invalid`, 3 runs of
3) and one is a flake with a 20-second timing margin (TC_044.2, 1 pass in 3).
Neither is demoted in the scope table: `tck/scope.ts` forbids demoting a row to
`NOT_APPLICABLE` to make a red scenario go away, and a TCK whose second driver
reports 100% green is a TCK that has stopped measuring. Both are below.
OCPP 1.6 reservation endpoints. The single failure is a deterministic finding
against CitrineOS: TC_023.3, `Blocked` answered as `Invalid`. It is not demoted
in the scope table: `tck/scope.ts` forbids demoting a row to `NOT_APPLICABLE`
to make a red scenario go away, and a TCK whose second driver reports 100%
green is a TCK that has stopped measuring. It is below.

The count was 38 / 7 / 2 on 2026-08-11. The second failure was TC_044.2, and it
was **ours**: the scenario asked for a retrieveDate +90s against a 110s hold,
leaving ~20s for the status train. That is fixed in `tck/specs/firmware.ts`, so
what remains is the CitrineOS finding alone.

A second CitrineOS defect is reported and NOT counted here, because these
scenarios cannot see it: OCPP 1.6 `FirmwareStatusNotification` is answered with
a `NotSupported` CALLERROR where OCA TC_044 puts a `.conf` on the Central
System ([citrineos/citrineos#216][i216]). Our assertions read only what the
CHARGE POINT sent, never the CSMS's answer to it, so three rows stay green over
nine CALLERRORs. That is a gap in the scenarios, not evidence about CitrineOS —
`scope.ts` says so on each of those rows.

The interesting result is the other 38. The core, remote-trigger, smart-charging,
local-auth-list and firmware groups all pass unmodified against a CSMS that had
Expand Down Expand Up @@ -114,8 +134,10 @@ Two differences, both read off the running images rather than inferred:
was already version-agnostic; only the record reads needed it.

**Measured, 2026-08-11: 18 `PASS`, 13 `NOT APPLICABLE`, 16 `FAIL` out of 47**,
against 38 / 7 / 2 on v2 — and the gap is one upstream defect rather than a
driver limitation. Fourteen of the fifteen `all`-group failures answer every
against 39 / 7 / 1 on v2 — and the gap is one upstream defect rather than a
driver limitation. That v1 row has **not** been re-measured since the
retrieveDate fix or the move to the GraphQL transport, so read it as the
2026-08-11 snapshot it is; the v2 figure beside it is current. Fourteen of the fifteen `all`-group failures answer every
`StartTransaction` with `{"idTagInfo":{"status":"Invalid"},"transactionId":0}`,
and the database holds `Connectors: 0` after the whole sweep: that is
[citrineos/citrineos#160][i160], closed 2026-05-19 — *after* v1.9.1 shipped on
Expand Down Expand Up @@ -158,10 +180,8 @@ them.
| `CITRINE_API_URL` | `http://localhost:8080` | Message-API base. |
| `CITRINE_WS_URL` | `ws://citrine:8081/` | OCPP endpoint; the charge point id is appended as the last path segment. |
| `CITRINE_TENANT_ID` | `1` | Carried by every API call and every query. |
| `CITRINE_DB_CONTAINER` | `citrine-db` | Postgres container reached by `docker exec`. |
| `CITRINE_DB_USER` | `citrine` | |
| `CITRINE_DB_PASS` | `citrine` | Passed as `-e PGPASSWORD` with no value, so it never reaches docker's argv. |
| `CITRINE_DB_NAME` | `citrine` | |
| `CITRINE_GRAPHQL_URL` | `http://localhost:8090` | The `graphql-engine` sidecar: records and fixtures both go through it. |
| `CITRINE_HASURA_SECRET` | *(unset)* | `x-hasura-admin-secret`, when the target sets one. Upstream's compose and ours do not, so the header is omitted rather than sent blank. |
| `CITRINE_NETWORK` | `citrineos_citrineos-internal` | Docker network the simulator joins. |

An explicit `SIM_*` value still beats all of these — an operator's override is
Expand Down Expand Up @@ -194,23 +214,33 @@ the action, which is the only reason a table is needed:
| `ClearChargingProfile` | `smartcharging/clearChargingProfile` |
| `ReserveNow`, `CancelReservation` | **none** — see the gaps below |

Observations come from Postgres through `docker exec`, because CitrineOS's REST
data endpoints expose none of what the scenarios assert on. Every
`@AsDataEndpoint` in the repository was read to establish that.

The bundled Hasura sidecar was the alternative, and [`records.ts`](records.ts)
carries the full argument. The short version: it would **not** decouple the
driver from the schema — Hasura derives field names from column names, so the
v1.9.1 → v2 column rename would have broken the same queries the same way — and
it is part of CitrineOS's *dev compose* rather than its product. What it would
genuinely buy is remote testability, and that is the trigger to revisit it.
Measured cost of the current transport: **~350 ms per query**, almost all of it
`docker exec` spawn; the cheap fix there is a persistent psql session, not a
different API.

**Consequence: this driver must run on the host that owns the containers.** The
operation half would work against a remote CitrineOS; the record half would
not.
Observations and fixtures go through the **GraphQL data API** (Hasura), because
CitrineOS's REST data endpoints expose none of what the scenarios assert on:
there is no "latest transaction for this station" (the one transaction route
requires the `transactionId` you are trying to find), no idTag on a
transaction, no stop reason, no count, and no Authorization CRUD at all. Every
`@AsDataEndpoint` in the repository was read to establish that, and every route
was probed on the pinned image.

Using GraphQL is CitrineOS's own answer rather than a workaround:
`packages/ocpi-base` — a shipped server-side package — creates Authorizations
with `insert_Authorizations_one`, the operator UI uses the same mutations, their
e2e suite seeds fixtures through a `GraphQLClient`, and their compose starts
`graphql-engine` ungated while gating the UI and the OCPI server behind
`profiles:`. The server even *demands* it: `sendLocalList` answers `"Authorization
not found for idTag '…' (create the Authorization before adding it to a local
auth list)"`, and no REST route can create one.

What it does **not** buy is insulation from the schema: Hasura derives field
names from column names, so the v1.9.1 → v2 column rename breaks these queries
exactly as it broke the SQL. What it buys is that **this driver never shells
into a container** — both halves are HTTP, so it can be pointed at a CitrineOS
nobody on this host owns.

`ocpp-tck driver provision` tracks the tables and the three relationships the
queries need through Hasura's metadata API, so nothing of CitrineOS's own
metadata is vendored here. That bootstrap is the counterpart of the SteVe
driver writing an API password and restarting the container.

One thing this driver does *better* than the SteVe one: `SendLocalList` is
lossless here. SteVe's manager UI carries tag names only, so per-entry `status`,
Expand All @@ -229,8 +259,8 @@ a `reason` that cannot name the limitation is `CONDITIONAL`, not
| **Local auth list is v2-only.** `EVDriverOcpp16Api` gained `sendLocalList` / `getLocalListVersion` in the v2 line. | 6 scenarios, drivable only on the pinned prerelease. | `packages/core/src/modules/EVDriver/src/module/1.6/MessageApi.ts` |
| **No charging-profile registry.** `ChargingProfiles` has no `description` or `name` column, and nothing to look one up by. | `refByDescription` resolves from this driver's own catalogue instead. Not a scenario cost: OCPP 1.6 carries the profile inline. | `packages/core/src/dal/layers/drizzle/schema/ChargingProfile.ts`, and [`profiles.ts`](profiles.ts) |
| **`Blocked` is unreachable from the 1.6 `Authorize` path.** The handler reaches its status mapper only through the `status === Accepted` branch, so a stored `Blocked` falls through to the default `Invalid`. The only route to a real `Blocked` is an `IAuthorizer`, and the container registers `authorizers: asValue([])` with no setting that changes it. | TC_023.3 — see `scope.ts` for its current status. | `AuthorizeRequestOcpp16Handler.ts`, `apps/ocpp-server/src/container.ts` |
| **No REST for `Authorizations`.** `EVDriverDataApi` exposes exactly one route, a read-only local-list-version GET. | `driver provision` writes fixtures with SQL. | [`provision.ts`](provision.ts) |
| **Four foreign keys reference `Authorizations`, none cascading**: `Transactions.authorizationId`, `LocalListAuthorizations.authorizationId`, `LocalListAuthorizations.groupAuthorizationId`, and the self-reference `Authorizations.groupAuthorizationId`. | `teardown` derives its `NOT EXISTS` guards from `pg_constraint` instead of listing them, so a fifth on a future CitrineOS is picked up rather than aborting the whole delete. Guarding only the first was measured to leave *every* fixture in place, because psql runs the script in one implicit transaction. | Read from the live catalog; see `guards()` in [`provision.ts`](provision.ts). |
| **No REST for `Authorizations`.** `EVDriverDataApi` exposes exactly one route, a read-only local-list-version GET. | `driver provision` writes fixtures through GraphQL. | [`provision.ts`](provision.ts) |
| **Four foreign keys reference `Authorizations`, none cascading**: `Transactions.authorizationId`, `LocalListAuthorizations.authorizationId`, `LocalListAuthorizations.groupAuthorizationId`, and the self-reference `Authorizations.groupAuthorizationId`. | `teardown` derives its guards from the foreign keys Hasura reports instead of listing them, so a fifth on a future CitrineOS is picked up rather than aborting the whole delete. Guarding only the first was measured to leave *every* fixture in place, because psql ran the script in one implicit transaction. | Read from the foreign keys Hasura derives; see `references()` in [`provision.ts`](provision.ts). |
| **`Blocked` is unreachable from the 1.6 `Authorize` path** *(see the row above for the mechanism)*. | **TC_023.3 fails**, deterministically: CitrineOS answers `{"idTagInfo":{"status":"Invalid"}}` where the scenario requires `Blocked`. Observed 3 runs out of 3. | Kept red on purpose — see `scope.ts`. |
| **No 1.6 request handler for `FirmwareStatusNotification`.** Every one the charge point sends is answered with `[4,…,"NotSupported","No handler found for action: FirmwareStatusNotification at module configuration"]` — 9 across the three TC_044 logs, and the only CALLERROR the CSMS emits anywhere in the suite. | **A non-conformance, and one this suite does not detect.** OCA `TC_044_{1,2,3}_CSMS` put steps 4 and 6 on the Central System — *"The Central responds with a FirmwareStatusNotification.conf"* — and a CALLERROR is not that conf. Our scenarios pass because they assert only on the statuses the charge point *sent*. See below. | `packages/core/src/handlers/requests/1.6/` — `DiagnosticsStatusNotification` has one, `FirmwareStatusNotification` does not. No ticket upstream. |
| **No 1.6 response handler for `UnlockConnector` or `UpdateFirmware`.** The Calls are routed and sent; the CallResults are answered with the same `NotSupported` CALLERROR. | Harmless — the six affected scenarios all pass. | `packages/core/src/handlers/responses/1.6/` |
Expand Down Expand Up @@ -318,3 +348,5 @@ Neither is listed as a gap, because they cost nothing once known:
- **A tag stored as `status = 'Expired'` answers `Invalid`, not `Expired`.**
The expiry is consulted only inside the `Accepted` branch, so `CERT023-EXP`
is provisioned as `Accepted` with a past `cacheExpiryDateTime`.

[i216]: https://github.com/citrineos/citrineos/issues/216
Loading
Loading