lims — rung 6 of the application ladder
Status: built — the rung named a design annex under the round-7
program decision has since been constructed: §1–§7 below are
all implemented, with 130 TEST_CASEs across tests/ and a full
QML GUI under gui/qml/; see Definition of
done for what that does and does not mean. A
lightweight Laboratory Information Management System:
register samples, assign analyses, capture results with real units and
detection limits on versioned forms, verify and publish, keep a regulatory
audit trail — with offline data capture in the field. The deepest test of
morph's headline claim ("exact values for financial/lab data") and of the
forms subsystem at full depth.
Three anchors, each for a different layer:
- SENAITE (Python/Plone, GPL) —
the domain reference. Its code is Zope-era and not worth reading; its
requirements are gold: sample → analysis request → result → verify →
publish workflow, detection limits (
< LOD,> UDL), instrument interfaces, and an immutable per-change audit trail built for 21 CFR Part 11-style compliance. Mine the docs and data model, reimplement clean: https://www.senaite.com/ - InvenTree (Python/Django,
MIT) — the units reference. It embeds the pint unit library end-to-end:
parameter templates declare a base unit, users enter values in any
compatible unit ("1500 mA against a template in A") and the system
converts exactly, including in API filters; custom units are definable.
Reproduce this flow with
morph::units::Quantity+UnitTraits<E>::relations(entry-unit alternatives with exact ratios). Docs: https://docs.inventree.org/en/latest/concepts/units/ - ODK Central (Node, Apache-2.0) —
the forms + offline reference. Its entire product is "upload a versioned
form schema, clients render data-entry UIs from it, offline". Two features
to reproduce:
- versioned form definitions (XLSForm/XForms → here: versioned
morph::formsschemas served by the model); - offline Entities (v2024.3+): field workers create and update shared records offline; every update carries a target base version; the server flags a conflict when the base is stale and a human resolves it. This is exactly morph's shared-instances + offline-queue + replay, with a published conflict-semantics answer to compare against. Design discussion: https://forum.getodk.org/t/43272, spec: https://getodk.github.io/xforms-spec/
- versioned form definitions (XLSForm/XForms → here: versioned
Models: SampleModel keyed by sample id (shared instance — bench and office
clients attach to the same sample), AnalysisCatalogModel (analysis
definitions = form schemas, versioned). — struck
after the rest of the rung was built; see resolved decision 19 for the
reasoning.WorksheetModel
Entities: client/project, sample, analysis definition (name, unit, entry units, decimal places, specification range, LOD/UDL), analysis result, verification record, audit entry.
Build order:
- Analysis catalog: define an analysis with unit, precision, and spec range
→ the served JSON Schema is the result-entry form (
x-decimalPlaces,ExtUnits,x-unitAlternatives, bounds). - Sample registration + lifecycle state machine (registered → received → in-progress → to-be-verified → published), each transition a guarded, journaled action.
- Result entry with units:
Quantity<Unit>fields; entry-unit conversion (mg/L ↔ µg/L exact); empty-Quantity = "not measured"; detection limits as typed values. Resolved by the round-5 review — the forms palette has no sum types (closed by design):ResultValue = quantity | belowLOD | aboveUDLis implemented as the multi-field encoding (aQuantityplus a qualifierChoice) glued bymutuallyExclusive/exactlyOneOfx-rules; the rung proves that encoding round-trips distinguishably through wire, journal, and offline payloads (three "no number" meanings — D-test in the review). Native sum types go on the framework-gap ledger, not this rung's critical path. - Schema versioning: editing an analysis definition creates version
N+1; old results stay bound to their version; clients render the version
the result was captured with (ODK's form-version model). Scope
correction (round 5): serving stored v-N schema text renders fine (the
client machinery is data-driven), but validation,
x-rules, and precision reconciliation always run against the current compiled struct — "bound to their version" holds for rendering only; validating a v-N payload under v-N rules is a named framework gap. The render-v1/validate-v2 skew test (review D4) is mandatory and needs no socket. - Conditional form logic: fields required/visible depending on other
fields (e.g. dilution factor only when diluted).
requiredWhen/visibleWhen/readonlyWhenwith single-node conditions exist and are enforced client- and server-side. Correction (rung 6, verified against the shipped headers): round 5's "there are noand/or/notcombinators" is out of date —andOf/orOf/notOflanded in commit 332f82c ("forms+qml: and/or/not rule conditions", #78), are documented indocs/spec/forms/forms.md("Compound conditions"), and are usable both nested in awhenclause and directly as a top-level rule. What remains true: the vocabulary is still closed (no application lambdas), a hidden field's draft value still travels (decide clear-on-hide), and comparison rules are vacuously true on unengaged operands whileequalsis false — test the parity suite on served schema data including a fail-closed unknown rule kind (review D8). - Verification + audit: four-eyes verify step gated by
IAuthorizerrole; the full audit trail rendered from the journal (SENAITE's immutable snapshot requirement). - Offline field capture — the rung's centerpiece: a WASM/desktop client
takes samples in the field, disconnected; results queue in
SqliteOfflineQueue; each queued update carries the sample's base version; on reconnect, replay detects stale bases server-side and flags conflicts for human resolution instead of silently merging (the ODK answer, implemented on morph primitives).
Unit algebra + exact conversion end-to-end; runtime schema-driven forms at their hardest (tagged unions, conditionals, versioning); shared sample instances; offline queue with explicit conflict semantics; role-gated transitions; journal as regulatory audit.
- Tagged-union result values and cross-field conditional logic are beyond
plain JSON Schema — this rung maps the exact edge of
morph::forms. Wire-level corollary: three distinct "no number" meanings (emptyQuantity,belowLOD,aboveUDL) must round-trip distinguishably through glaze and through the offline queue's opaque payloads. - Schema versioning: morph serves schemas from compiled C++ types; versioned catalogs mean schemas become data. Bridges toward rung 7's runtime custom fields.
- Journal payload evolution — this rung owns the ladder's answer
[framework gap]: replay decodes stored payloads with the current
action structs; rename or retype a field and old entries decode
leniently, silently dropping data — the "reconstructible from the journal
alone" DoD is then false. Versioned analyses make this unavoidable:
per-entry schema/app-version pinning plus a migration story (the journal
format's
vcovers the line format only). Rungs 5 and 7 reuse whatever is decided here. - Stale-schema submission: schema
required/bounds are client-side only — the server runs whatever payload arrives. A v-N payload against a v-N+1 server (narrowed spec range) must be accepted-under-old-rules, rejected, or migrated — pick one and prove it. The journal half of real binary skew is now proven by two separately compiled binaries (tests/compile_checks/journal_skew_probe.cpp): an old build records, a new build replays, and a renamed field throwsSchemaMismatchErrorrather than decoding a lab result to a default. An additive field throws there too —replay()'s gate is fingerprint equality, not compatibility — and is admitted by a pass-through migration. The wire half (an oldMORPH_CLIENT_ONLYclient against a new server) stays open: nothing mechanically enforces the action-evolution policy on that path until issue #207's per-actionhellofingerprint exists to assert on. - Self-conflict in the offline chain: one field client editing the same sample twice offline — the second queued update's base version must reference the first queued update, not the server state, or replay flags the client's own second edit as a conflict (ODK hit exactly this).
- Precision through unit relations — the rule exists; test it, don't
redesign it (round-5 correction): conversion carries the dp tag through
unchanged, the renderer always submits in the canonical unit at the
schema's
x-decimalPlaces, and alternative-unit display rounds half-up. What to test instead: (a) retag-vs-round — resolved upstream in issue #159, which madereconcileDeclaredPrecisionre-round rather than retag; this rung keeps its own stricter rejection because the governing precision is the analysis version's runtime value, not the compile-time declared one (review D1, §7 below); (b)x-unitAlternativeslists direct relation edges only, so InvenTree-style "enter in any compatible unit" needs a deliberately complete relations array; chained ratios are not cross-checked; (c) the shipped QML converter silently clears input above a 1e12 divisor — exactly the fine-ratio range of trace-concentration relations (ng/L↔mg/L); (d)std::optional<Quantity<U>>silently loses all unit annotations — use emptyQuantity/optionalFields, and lint for the optional spelling. - Empty-principal audit entries: the authorize/authenticate TOCTOU can dispatch with a cleared principal; in a 21-CFR-framed audit trail that is disqualifying. Deterministic test via the injectable token clock; models refuse empty principals on mutating actions.
- Base-version conflict detection is app logic today — evaluate whether a reusable morph primitive should exist.
- Offline field capture in the browser inherits kanban's WASM-offline scope
limits (
../kanban/README.md) — desktop-first.
- The "1500 mA vs A" InvenTree flow works with exact conversion in a generated form.
- Offline capture demo: two field clients update the same sample offline; reconnect flags exactly the stale-base update as a conflict.
- Audit trail passes the SENAITE-style test: every state a sample was ever in is reconstructible from the journal alone — under the payload evolution scheme this rung defines, verified by replaying a journal recorded before a schema migration.
Recorded as they are taken, per the LADDER.md discipline
rule. §1–§7 are all built, with 130 TEST_CASEs across
tests/ and a full QML GUI under gui/qml/.
lims_analyses holds the identity; lims_analysis_versions holds
everything a revision can change (unit, precision, spec range, LOD/UDL).
Version rows are append-only: ReviseAnalysis inserts N+1 and never
updates N. A result names a version, never an analysis. This is what makes
"an old result stays bound to the definition it was captured under" a
property of the data rather than a convention the code has to keep
remembering.
Quantity's convert composes a path through the relation graph (BFS,
detail::conversionRatio), but unitAlternatives() — the source of the
schema's x-unitAlternatives — reports direct neighbours only. Leaving
the ng/L → mg/L edge to compose out of ng/L → µg/L → mg/L would keep the C++
conversion working while the generated form silently stopped offering ng/L
as an entry unit. The redundant-looking edge is load-bearing, and
test_result_entry.cpp guards it.
Not a single TransitionSample{target}. A journal entry that cannot name
the operation is not an audit record. The guard itself is a free
constexpr function, isLegalTransition(from, to), so the whole 6×6 matrix
is exhaustively testable with no database in the picture.
Self-edges are illegal: absorbing a repeated ReceiveSample as a no-op
would journal a transition that did not happen. Published and Rejected
are terminal. ToBeVerified goes forward to Published or back to
InProgress — a worked sample has results, and rejecting it outright would
discard them silently.
"Who tried to publish an unverified sample" is precisely the question a
21 CFR Part 11-style trail exists to answer, so a rejection appends an entry
with Outcome::Failed and the exception text. The single exception is
EmptyPrincipalError, checked before the recording block: an attempt with
no authenticated principal must produce no entry at all, because an audit
entry naming nobody is the failure mode this README's own "Expected strain
points" calls disqualifying. Both halves are tested and both fail if the
ordering is swapped.
lims_samples.version is bumped by each transition and by each captured
result, inside the same transaction as the change itself. It is the value
§7's offline updates will target; a reader that could observe a new state at
an old version would have lost the property conflict detection depends on
before that code is written.
The forms palette has no sum types (closed by design). The encoding is two fields and one rule:
exactlyOneOf(&CaptureConcentration::value, &CaptureConcentration::qualifier)
A reading engages value and leaves qualifier empty; a non-reading does
the reverse, with qualifier carrying notMeasured / belowLOD /
aboveUDL. exactlyOneOf makes "0.5 mg/L, and also below the detection
limit" unrepresentable rather than merely discouraged, and one declaration
drives both sides: the renderer reads it from x-rules, the server re-runs
the same compiled rule list because validate() calls allRulesSatisfied.
The three "no number" meanings are three distinct strings in one Choice,
not one "empty" flag, which is what makes the required distinguishability
mechanical. test_result_entry.cpp proves it pairwise-distinct through the
wire codec, through a LogEntry line round trip, and through an offline
queue payload.
Decoding a qualifier code is fail-closed: an unknown code is rejected,
never resolved to notMeasured. Defaulting would turn "this client speaks a
dialect we do not know" into the lab asserting it never looked — a
fabricated claim rather than a parse failure.
Upstream issue #159 asked whether x-decimalPlaces "enforcement" should
round the value, be redocumented as advisory, or reject an over-precise
submission. The framework took the first option: reconcileDeclaredPrecision
now re-rounds on its wire dispatch paths, so storage and display agree there.
A reading finer than the method supports is a claim about the instrument, so rounding it would record a measurement the analyst never made, and storing it unrounded would put a number in the database that no display of it ever shows. Either way storage and display disagree, which is disqualifying in a LIMS, so this rung rejects the payload.
The precision it rejects against is the analysis version's, not the
compiled Quantity<mg_per_L, 3>'s. That is now expressed as a
morph::forms::InstanceConstraints (upstream issue #164) rather than a
hand-written check: the same declaration that decorated the served form's
x-decimalPlaces reports precisionExceeded here, so the number the
operator's renderer honoured and the number the server enforces are one
number. The check itself is still exact and overflow-free — Rational keeps
gcd(num, den) == 1, so a value is representable at d decimals exactly
when den divides 10^d.
CaptureConcentration::value is a Quantity<LimsUnit::mg_per_L, 3>; the
only way it can be wrong about its unit is if the analysis version it names
is denominated in something else. The model checks
version.canonicalUnit == Concentration::unitMeta().id and refuses
otherwise — storing an amps reading in the mg/L column would be undetectable
afterwards.
A result is the lab's current answer for one analysis on one sample; two live rows would make "the sample's results" ambiguous. The superseded value is not lost — the journal holds every capture, which is exactly where a 21 CFR-style trail expects to find it.
GetAnalysisSchema{versionId} serves the result-entry form for one version:
the compiled CaptureConcentration schema with that version's own precision
and specification range written into the framework's own keys
(x-decimalPlaces, x-minimum, x-maximum) via
morph::forms::InstanceConstraints, plus its detection limits as the
app-private x-limitOfDetection / x-upperDetectionLimit. The document
carries x-instanceConstraints: ["value"], naming which keys came from the
row rather than from the compiled type. Revising an analysis leaves the old
version's served form byte-identical, which is the ODK property the README
asks for.
Corrected by upstream issue #164. This rung originally served the
per-version precision as a second key, x-versionDecimalPlaces, beside the
framework's x-decimalPlaces, because overwriting the framework's key would
have advertised a promise no code kept. Two keys for one concept, with no way
for a renderer to know which to believe, was worse than either alone. The
framework now has a seam for it: one declaration both decorates the schema and
checks the submitted reading, so the advertised number and the enforced one
cannot drift apart. x-versionDecimalPlaces is gone.
The form's shape is still compiled and therefore identical for every
version: the required array and the x-rules list come from
CaptureConcentration, so a version wanting an extra field or a different
rule still cannot be served without recompiling. That boundary is unchanged,
and it is what rung 7's runtime custom fields run into head-on.
GetAnalysisSchema refuses a version whose canonical unit has no
compiled result-entry action, rather than serving the mg/L form for an amps
analysis.
Corrected after the backend-mode matrix was written. The classification
logic — base-version comparison, conflict flagging, at-most-once — lives in
SampleModel::execute(QueuedCapture), a registered action. The supported
replay path is therefore a re-dispatch: a reconnecting client drains its
own queue and sends each item as an ordinary action through its authenticated
Bridge. test_backend_matrix.cpp runs that across all three deployment
modes.
SampleModel::onBackendChanged() is also implemented, and it is what
docs/spec/offline/offline.md names as the seam for exactly this. It cannot
be the primary path here: switchBackend posts the call onto the model's
own strand, where session::current() is null, so every queued item is
refused for want of a principal. A lab reading replayed with no identified
author is what this README calls disqualifying, so failing closed is right —
but it does mean the framework's own replay seam cannot carry an
authenticated replay. That is morph#201, and it was found only by
driving replay through switchBackend instead of calling the hook directly;
the §7 suite's own helper calls it from a thread that has a session
installed, which the framework never does.
The write half could not use a framework seam either: there is no
enqueue-on-failure hook, and the machine that must make the decision (a
disconnected field client) has no model on it at all.
include/lims/offline/field_outbox.hpp is the app-layer answer, and
morph#197 is the finding — now dispositioned: IMPLEMENTATION.md rule 1
carries a named carve-out for this seam, and FieldOutbox is the shape it
points at (docs/spec/offline/offline.md, "Disposition: app-layer by design").
So neither end of the offline round trip goes through a framework seam. That is the honest summary of §7's framework story.
execute(QueuedCapture) returns ReplayOutcome::Conflicted rather than
throwing. Throwing would abort the replay of every later item in the queue
and lose the very flag the run exists to raise. IMPLEMENTATION.md's "never
encode failure as a magic value in a result DTO" is about failures; a
stale-base update is a legitimate business outcome that a human must see, and
the two-enumerator result enum is the same shape as polls::Restored.
Genuine errors still throw: an envelope that does not validate, an author who is not the authenticated principal, a sample or analysis version that does not exist.
QueuedCapture::capturedBy must equal the authenticated principal, or replay
refuses with Forbidden. A queued reading is replayed as the operator who
took it and nobody else — in a 21 CFR Part 11-style trail the author of a
reading is not a field a client may assert freely. ApplyAnyway resolution
keeps that attribution: the stored result names the field operator, the
conflict row names the resolver, and the journal has both.
A sample that has left InProgress has also moved on in version, so both
LifecycleClosed and StaleBase are true. The flag says LifecycleClosed,
because "somebody submitted this for verification while you were away" is a
more actionable thing to tell a human than "your base was stale".
lims_replayed_ops records every operation key this server has decided —
applied or flagged — so a redelivery is skipped rather than acted on
twice. Without it, a second delivery would bump the version again and then
start flagging the client's own later updates as stale.
This is where docs/spec/offline/offline.md puts the enforcement ("the queue
… never interprets, requires, or enforces uniqueness on it — enforcement is
the replay consumer's job"), and it is also the only way to be correct on all
three shipped queues, which disagree about whether they dedup at enqueue time
(morph#175).
The operation key is a minted random 128-bit id, per the spec's own
recommendation. A counter was tried first and was wrong: FieldOutbox holds
it in memory, so a device switched off at the end of a shift restarts the
count at zero and mints keys colliding with genuinely different earlier
edits — replay would then silently skip a real reading. The rung's own
self-conflict test caught it.
The verifier must hold the role and must not be the analyst who
captured the reading. Only the first is a type-level fact, so only the first
can be checked before dispatch: lims::auth::LimsAuthorizer does it at the
RemoteServer edge, and SampleModel re-does it on every dispatch path,
because no authorizer runs in Local mode at all. The second is a row-level
fact IAuthorizer structurally cannot express — it is handed the model and
action type ids and the caller's session, never the result — so it lives only
in the model. Neither check stands in for the other, and both are tested.
Roles are not hierarchical: a supervisor who must also verify is granted
Verifier explicitly. An implicit hierarchy is how somebody ends up being the
second pair of eyes on their own work without anyone deciding they should be.
LimsAuthorizer derives from SigningAuthorizer rather than
AllowAllAuthorizer (polls' choice) because a role gate needs an
authenticated principal: a non-authenticating authorizer makes RemoteServer
clear Context::principal before dispatch, leaving nothing to key on. Roles
come from the lims_operators table, not the token's roles claim — a token
lives for its expiry, so a role revoked mid-shift would keep working until it
aged out.
The bootstrap carve-out. A lab with no supervisor cannot appoint its
first one, so the first GrantRole is allowed whoever makes it. The exception
closes the instant any supervisor exists and the grant is journaled like every
other, so it is visible in the trail rather than a back door.
LimsAuthorizer deliberately does not mirror the carve-out — duplicating a
security exception is how the two copies drift — which means the very first
grant must be made locally, never remotely against an empty lab.
Publishing requires verification. A four-eyes control nothing depends on
is theatre, so PublishSample refuses while any captured result is
unverified.
execute(GetAuditTrail) reads the attached action log and opens no
DataMapper at all. That is what makes the definition of done — "every state
a sample was ever in is reconstructible from the journal alone" — a claim the
method can support rather than assert, and the test asserts it the only honest
way: it deletes every row the sample has and reconstructs anyway.
An entry this build cannot interpret becomes an AuditStepKind::Unreadable
step, never a skipped one. Skipping is the tempting implementation and the
wrong one: a trail that quietly omits what it could not read looks complete,
so nobody discovers the omission.
What that does not cover, stated plainly. Unreadable catches an unknown
action id and a result that does not parse. It cannot catch a payload that
parses into something else — a renamed field decodes to a default, silently,
so a trail reconstructed across a rename is confidently wrong rather than
visibly incomplete. That was morph#174's diagnosis, and the framework has
since answered it — journal::replay() now stamps every entry with a
payload-shape fingerprint and refuses to replay across a mismatch instead of
degrading to defaults (docs/spec/journal/journal.md). This rung's own
reconstruction above does not go through journal::replay() — it decodes
each entry's stored result with ActionTraits<X>::resultFromJson directly —
so it still has the gap the framework itself no longer has; adopting
journal::replay()'s fingerprint check here is unmade work, not an unmade
decision.
CaptureConcentration gains the README's own example: a dilution factor
required, and shown, only when the preparation says the aliquot was diluted.
Both rules are declared — requiredWhen and visibleWhen, over the same
equals(dilution, "diluted") condition — because neither implies the other,
which is the framework's stated contract rather than an oversight.
Clear-on-hide: decided as "do not". A hidden field's draft value still
travels; the framework says so and this rung does not fight it. Instead the
server ignores a dilution factor whose preparation says neat, so an
operator who selects diluted, types a factor, then switches back to neat
gets 2.4 mg/L rather than 24 — and gets it whether or not their renderer
bothered to clear the field. Clearing client-side would have made the outcome
depend on renderer behaviour the server cannot verify.
The factor is load-bearing. The model multiplies the reading by it,
exactly (Rational, never a double), so a mis-declared dilution changes the
reported concentration. A conditional field nothing depends on would test the
rule vocabulary and nothing else.
requiredWhen can insist a field is filled in; it cannot insist the number
means anything, so the model separately refuses a factor of zero or less, and
fail-closes on an unknown preparation code exactly as it does for an unknown
qualifier code.
The parity suite. test_conditional_logic.cpp evaluates the served
x-rules JSON with a generic, data-driven evaluator — the one a non-QML client
has to write — and asserts it agrees with the compiled allRulesSatisfied
across all 24 points of the rule state space, with the matrix asserted to
contain both verdicts so a client that always says yes could not pass. It also
pins the two behaviours D8 names: equals is not vacuous on an unengaged
field (unlike the comparison kinds), and an unrecognised rule kind fails
closed on evaluation while deferring on enforcement — the client does not
claim the rule holds, but still submits, because a client that blocked on an
unknown rule could not talk to a newer server at all.
That evaluator is the third implementation of one closed vocabulary the framework owns, which is morph#176.
A worksheet, in SENAITE's sense, groups analyses drawn from several samples so one analyst can run them together — twenty nitrate determinations from twenty samples on one plate. It is a work-organisation unit, not a data one.
It is not built, and the "What to implement" list above is corrected rather than left carrying an unfulfilled promise. The reasoning, so a later reader can disagree with it on the merits:
Nothing in the rung's definition of done needs it. The three DoD items are the InvenTree unit flow, the offline conflict demo, and the audit trail; a worksheet appears in none of them.
It would exercise no morph surface this rung has not already stressed. Every subsystem the README's own "morph subsystems exercised" list names — unit algebra, schema-driven forms at depth, shared sample instances, the offline queue's conflict semantics, role-gated transitions, the journal as audit — is covered without it.
Its one genuinely interesting property is neutralised by a decision this
rung already took. A worksheet writes results for samples whose own
SampleModel instances may be live, which is exactly where the
cross-instance staleness hazard examples/bank's README documents ("The
honest edge") lives. It does not arise here: this rung's models cache nothing
but the id they are attached to and re-read the store on every call, so a
second writer is visible immediately. Building a worksheet to demonstrate a
hazard the design already forecloses would demonstrate nothing.
The one gap it would re-hit is already filed. A batch assignment
(vector<SampleAnalysis>) is an array-of-objects field, which DynamicForm's
array control does not render — a gap examples/polls hit and recorded first.
Re-confirming it costs a model and a table and produces no new finding.
What would change the answer. A worksheet becomes worth building the day this rung needs an operation that is atomic across several samples — one transaction covering rows owned by several keyed instances. That is a real framework question (morph serialises per instance, not across instances) and none of §2–§7 raised it. If a rung 7 or 8 needs cross-instance atomicity, the worksheet is the natural place to put it, and this decision should be revisited then rather than treated as settled.
Two surfaces, one shell (gui/qml/Main.qml): the sample lifecycle and result
entry, side by side as tabs rather than stacked, because both act on the
same attached sample at once — a bench operator captures a reading while the
office watches the state move. Attaching the lifecycle surface points the
result surface at the same sample, and both handlers are AllowShared over a
keyed model, so they land on one instance. That is the shared-instance design
made visible rather than asserted.
gui_lib/ holds two presenter/QML-bridge pairs (SamplePresenter/
SampleBridge, ResultPresenter/ResultBridge) plus one shared conversion
header. Presenters link Qt6::Core only and instantiate under a plain
QCoreApplication (presenter rule 1); they track completions through the
common Presenter base so tests wait on busy() rather than sleeping (rule
3); QML is bindings-only (rule 6); and the offscreen engine-load smoke test
is registered in ctest.
examples/IMPLEMENTATION.md rule 2 permits no hand-built input widgets, and
this rung has none. The rule that decides which actions get a form:
A field a person types is rendered from the served schema. A value the model already supplied is a typed call.
So RegisterClient, RegisterSample, RejectSample, ReturnForRework,
CaptureConcentration and ResolveConflict are all rendered through the
shipped DynamicForm from gui_lib/lims_schemas.hpp's document. The
zero-field transitions (ReceiveSample, StartWork,
SubmitForVerification, PublishSample) are plain buttons, because an
action with no fields has no form to generate. VerifyResult and
OpenSample are typed calls: their one field is an id the table or spinbox
already holds, and asking somebody to retype an id they are looking at would
be worse, not more conformant.
The capture form is the one that earns its keep. Rendering
schemaJson<CaptureConcentration>() through the shipped renderer is what puts
this rung's own cross-field rules in front of the framework's evaluator
rather than only the one test_conditional_logic.cpp writes:
exactlyOneOf(value, qualifier) becomes the submit gate that makes "a number
and a below-LOD flag" unsubmittable; the requiredWhen/visibleWhen pair
makes the dilution factor appear and become required exactly when the
preparation says diluted; and x-decimalPlaces/x-unitAlternatives drive the
entry-unit machinery. None of that is written in QML.
Every DynamicForm here is left unbound (controller: null) and
submitted by an explicit button, following bookmarks' and pastebin's
precedent: a bound form auto-submits the moment its required fields are
engaged, which for a lab reading would file a result mid-keystroke.
A refusal is this rung's product, not its exception path — an over-precise
reading (decision 7), an exactlyOneOf violation (decision 6), a four-eyes
refusal (decision 16), an unknown qualifier or dilution code (decision 18) and
a rejected conflict resolution are each a statement about the measurement that
the analyst has to read. So both surfaces bind both channels the bridges have:
failed/lastErrorcarries the typed invokables' errors —openSample,refresh, the zero-field transitions,verifyResult. It is bound as the red label inMain.qmland at the foot ofResultEntryView.qml.replyReceived(actionType, ok, payload)carries every schema-driven form's outcome, both ways, with the model's ownwhat()as the payload whenokis false.submitIfValidis the only path those six forms have and it never routes throughfailed, so each view handlesreplyReceivedin aConnectionsblock — the same shape bookmarks, pastebin and polls use — and clears a form only once the submission was actually accepted.
test_lims_qml_surface.cpp asserts the second half rather than assuming it:
one case runs the QML-surface audit unexempted and requires that no finding
names replyReceived, so deleting either Connections block fails the build's
tests instead of quietly emptying the screen.
SampleModel is keyed, so the handler every attached action runs on is
AllowShared. But an AllowShared handler is not bound until it attaches
to a key, and RegisterClient carries none — dispatching it there fails
with "handler not bound" (confirmed empirically here; polls::gui:: PollPresenter reached the same conclusion for CreatePoll). So submitIfValid
routes that one action to a second, plain NoSharing handler, which registers
a fresh instance on construction and is bound immediately. RegisterSample
needs no such help even though it too arrives before any key exists: it is
result-keyed, so BridgeHandler::execute's ResultKeyed branch runs it on an
anonymous instance and promotes that instance to the id the result names
before the completion resolves — one dispatch, on the shared handler, and
that handler is attached when it returns.
RegisterClient's reply is decoded (the same glaze reflection the wire used)
to emit clientRegistered, since the form path yields raw JSON rather than a
typed result — clientRegistered is what sets the clientId property
SampleView.qml's "Latest client id" label binds (morph#309). With both
effects on the submitIfValid path itself, the typed registerClient/
registerSample invokables that morph#287 had exempted as "the only working
path" became genuinely redundant and are gone — the surface audit in
test_lims_qml_surface.cpp carries no exemption for either any more.
Both bridges used to publish a typed invokable and a schema-driven form for
the same action: rejectSample/returnForRework beside
submitIfValid("RejectSample") and its sibling, and captureReading/
captureQualifier/resolveConflict beside
submitIfValid("CaptureConcentration") and submitIfValid("ResolveConflict").
No QML called the typed half. Two paths to one action is two places for the
behaviour to differ, and here they already did: captureReading took the
reading as a double and converted it with Concentration::fromDouble at the
field's declared precision, so it rounded an over-precise reading that the
form path submits exactly and the model refuses (decision 7) — and it was the
only double on this rung's QML surface, against the convention
gui_lib/lims_qml_conversions.hpp states for a Quantity.
So the redundant half is deleted, together with the outcome signals only it
emitted (resultCaptured, conflictResolved), the sampleAttached signal
whose state Main.qml re-reads through refreshResults() instead, and the
bound relay neither view handles. rejectSample, returnForRework,
registerClient and registerSample are gone from SamplePresenter too,
since nothing else called them once submitIfValid carried their whole
effect. What survives is one path per action, and a QML-surface exemption list
with nothing on it.
The offscreen engine-load test proves every QML file parses and every type it
names resolves. It cannot prove a Connections handler is bound to a signal
that exists, or that a delegate reads a key the model supplies — both are
resolved dynamically against objects that test never supplies.
test_lims_qml_bridges.cpp covers that from the other side: it asserts
against the real bridges' metaobjects that every signal the QML connects to
exists, and against their real property bags that every key a delegate reads
is present.
codecov.yml gains a lims component scoped to examples/lims/src/models/**
and examples/lims/include/lims/models/** — the paths
examples/IMPLEMENTATION.md rule 5's bar actually names — with a 97% target,
a margin below the measured 855/864 = 98.96% ceiling.
The remaining nine lines are three defensive guards, each unreachable through
the model's own API rather than merely untested: alreadyDecided's empty-key
guard (validation refuses an empty operation key first), renderSchemaFor's
malformed-JSON arm (its input is schemaJson<A>()), and
GetAnalysisVersion's dangling-version arm (a declared foreign key forbids
the row). Each turns an impossible state into a typed error instead of a
dereference, so each is kept.
Measuring rather than assuming was worth it twice. The first measurement came
in at 93.27%, and the gap was not padding: AnalysisCatalogModel::attachActionLog
had no caller at all, so the catalogue's entire journaling path was
unverified — and the audit trail's VerifyResult branch was dead, because
verifications were journaled under an empty entity key. That was a real
defect rather than a coverage artifact: a sample's audit trail silently
omitted the second pair of eyes, which is the one thing a four-eyes record
exists to show. Fixed by rekeying the journal to the result's own sample
(same for conflict resolutions), and pinned by its own test.
Neither this component nor rung 5's could have scored anything before this,
because scripts/coverage.sh's rung list had drifted from CMake's and named
only rungs 1–4 — so ledger's carefully-measured 87% gate was matching a set of
files no uploaded report contained. Fixed here.
- morph#163
—
ModelKeyrejects strong id types. Closed upstream.BRIDGE_MODEL_KEYrouted the key throughkeyToString, whose concept admitted onlystd::integralorstd::string, whileIMPLEMENTATION.mdrule 3 mandates a strong id struct for entity identity. ledger and kanban carried the identical hand-written workaround; lims was the third, which is the rule-of-three trigger.ModelKeynow admits a strong id wrapping a raw key (WrappedModelKey), and morph#183 deleted all three rungs' hand-written blocks —SampleModelkeys onSampleIditself, and an empty id is refused bykeyToStringinstead of dereferenced. - The round-5 "no
and/or/notcombinators" claim is stale (build order §5 above, corrected in place). They landed in commit 332f82c (#78) and are specified indocs/spec/forms/forms.md. - morph#164
— a forms schema is a pure function of the compiled action type.
Partly closed upstream. Per-instance values now reach the framework's
own keys through
morph::forms::InstanceConstraints: the two analysis versions declaring 3 and 1 decimal places serve"x-decimalPlaces":3and"x-decimalPlaces":1, and a reading outside the served specification range is flagged (ResultView::outOfSpec) instead of being stored with nothing recording it. What remains is the structural half — the schema's shape is still a function of the compiled type, so a rung whose definitions are data needs one compiled action per unit family rather than per analysis. That is what rung 7's runtime custom fields run into head-on. - morph#174
— a journal entry from an older build decodes leniently to defaults, with no
signal. Closed at the framework level; this rung's own audit
reconstruction has not adopted it.
journal::replay()now stamps every entry withmorph::model::payloadFingerprint<Action>()and throwsSchemaMismatchErrorwhen a recorded entry's shape disagrees with what the running build decodes it as, instead of silently degrading to defaults (seedocs/spec/journal/journal.md, "Payload schema fingerprint"). This rung's ownSampleModel/ResultModelaudit-trail reconstruction (sample_model.cpp) callsActionTraits<X>::resultFromJsondirectly rather thanjournal::replay(), so it still has no fingerprint check and a renamed field still decodes to its default with no signal — the finding entry below, §17 in "What that does not cover, stated plainly," is accordingly still live for this rung specifically, even though the framework question it raised is answered. - A schema's
requiredarray can silently contradict its ownx-rules. Closed (morph#165).schemaJson's required-by-default rule put bothvalueandqualifierinrequiredwhile theexactlyOneOfentry beside them said at most one may be engaged — an unsatisfiable form.schemaJson<A>()now rejects exactly this at generation, throwingmorph::forms::UnsatisfiableFormErrorfromdetail::rejectUnsatisfiableRules(called frommergeSchemaExtras, the one place both halves are in hand), so the contradiction is a loud generation-time error instead of a silent one — documented indocs/spec/forms/forms.md, "Unsatisfiable declarations", whose worked example is this rung's ownCaptureConcentration. The sanctioned escape (optionalFields) is still what makes the rule the only gate on the pair;CaptureConcentration's own doc comment (include/lims/dto/result_dto.hpp) explains why it is still needed even though omitting it is now a loud error rather than a silent one. Guarded here by a test assertingrequiredis exactly["analysisVersionId"]. - morph#175
— the three shipped
IOfflineQueues disagree about repeated idempotency keys. Closed.InMemoryOfflineQueueadmits the duplicate;FileOfflineQueueandSqliteOfflineQueuededup — a deliberate strengthening the base contract permits but did not document. The interface header (include/morph/offline/offline_queue.hpp) now states the hit semantics explicitly: a dedup hit is first-write-wins with silent payload loss, matching whatdocs/spec/offline/offline.mdalready said. - morph#197
— the offline write path has no model-side seam. Rule 1 says all domain
logic lives in models; the offline spec says enqueue-on-failure is the
application's job at the dispatch site; and a disconnected field client has
no model to put it in anyway.
FieldOutboxis this rung's app-layer answer, and it carries a real invariant (a client's own successive offline edits must chain), not glue. Dispositioned as app-layer by design: rule 1 names the carve-out anddocs/spec/offline/offline.mdrecords the reasoning and the boundary; a framework primitive is reconsidered when a third rung grows its own enqueue path. - morph#172
—
MORPH_BUILD_OFFLINE_SQLITE=ONbreaks the build on macOS with a non-Apple clang.FindSQLite3resolves the SDK's whole/usr/include, which is then injected as-isystemahead of libc++'s own headers. The repo's ownmorph_offline_sqlite_teststarget fails identically, which is why the durable queue had never been built here before. - morph#176
—
x-ruleshas one client-side evaluator and no shared corpus. Closed. A shared corpus now pins the compiled evaluator (morph::forms::allRulesSatisfied) to the QML one: one checked-in file,src/qt/forms/tests/data/rule_corpus.json, is driven through both —tests/test_forms_rule_corpus.cppon the compiled side,tst_DynamicFormRuleCorpus.qmlon the client side — so a rule kind added to one evaluator and not the other now fails a shared test instead of shipping silently divergent (tests/forms_rule_corpus.hpp,tests/test_forms_rule_agreement.cpp). A non-QML client still has to reimplement the vocabulary; the corpus is what keeps that reimplementation honest against the compiled evaluator's behavior. - morph#173
— a ladder test whose name contains a semicolon never gets its
ladder-<rung>label.morph_add_rung's re-labelling step iteratesIN LISTS, which splits the name, soset_tests_propertiesapplies to nothing and nothing warns. Found here the hard way:ctest -L ladder-limsreported 85 cases while the binary reported 87. Two lims cases were renamed; 12 pre-existing ones repo-wide are still affected. - morph#199
— a
Quantity's exact decimal can only be rendered with its unit appended. Closed.toDecimalString(quantity)(include/morph/util/quantity.hpp) is now the public decimal-only renderer — the exact numeric half oftoString, no unit suffix,"N/A"for an empty quantity — so a view that places the number and the unit separately (a table with the unit in its column header, a right-aligned suffix) asks for the two halves independently instead of concatenating them and chopping the suffix back off. - morph#201
—
Model::onBackendChanged()runs with no session. Closed as a documentation correction, not a code gap.LocalBackendnever consults an authorizer at all, so no version of this hook could ever carry a verified principal — a session-plumbing fix was the wrong ask.switchBackendstill posts the hook onto the model's strand wheresession::current()is null, anddocs/spec/offline/offline.md's "Conflict resolution on replay" section now says so explicitly instead of implying the seam can authenticate a replay. Found by driving replay throughswitchBackendrather than calling the hook directly — the §7 suite's own helper had been supplying a session the framework never does. scripts/coverage.sh's rung list had drifted from CMake's. ledger and lims were both missing, so neither reached any uploaded report — and rung 5's codecov component, with a target derived from a genuinely careful measurement, was scoring files the report did not contain. A component that matches nothing does not fail; it reports nothing. Fixed here.- Models cannot self-journal without the registry/dispatcher path.
IModelHolder::recordIfAttachedfires only for holder-constructed models, so a plain-constructed one — the only kind a unit test has, and whatIMPLEMENTATION.mdrule 5 requires the audit trail to be tested through — records nothing.ledger(three models),kanban, and this rung's catalogue each hand-rolled the sameattachActionLog/logActionpair; this rung shares one copy between its two models (include/lims/core/self_journal.hpp) rather than adding a sixth. Not filed as a separate finding pending a decision on whether "models do not journal, dispatchers do" is the intended contract — but if it is, the contract makes the "reconstructible from the journal alone" DoD untestable at the model level, which is worth stating indocs/spec/journal/journal.mdeither way. - One compiled action type per unit family, not per analysis.
Quantity's unit and precision are template parameters, so a catalogue whose analyses are data cannot produce a result-entry action per analysis.CaptureConcentrationcovers every mg/L-denominated analysis and a second family (amps, temperature, pH) needs a second action type. This is the concrete shape of the README's "schemas become data" strain point and it bounds §4 as well.