Skip to content

Tasklist sweep: close what was already done, build what was buildable - #5

Closed
Boernsman wants to merge 9 commits into
mainfrom
tasklist-sweep
Closed

Boernsman wants to merge 9 commits into
mainfrom
tasklist-sweep

Conversation

@Boernsman

Copy link
Copy Markdown
Contributor

A review of the whole tasklist, implementing what could be implemented and closing what was already true. 57 open items before, 47 after, and three of the closures were defects rather than paperwork.

Defects found and fixed

The German fault screen was unreadable, and the instruction was cut off. 68 non-ASCII characters across 31 German cause strings, against a font that carries ASCII only — so the cause line had holes in it. And the causes run to 279 characters against a panel that holds five rows of 21, so 27 of them were truncated: for KILN_FAULT_CONTACTOR_WELDED the dropped part was "ISOLATE THE KILN AT ITS SUPPLY NOW". Each entry can now carry a panel form as well, and two gates name the offender rather than merely failing.

A log record appended outside a run was silently lost. Found while adding boot-time security events. It went into a sector with no header, which iterate() skips and the next begin_run() erases: written, stored, counted, lost. The first fix then left head_seq at 0, which made two sectors tie for newest and made SWA-09's power-loss journal read the wrong record — caught by three existing recovery tests.

Two test fakes were kinder than the media they stand in for. The key/value fake cleared a whole entry on erase and reused the delisted slot on the next write, either of which made SWR-CFG-09's "the secret is gone" assertion pass with no implementation behind it. It now delists and keeps the bytes, as NVS does. With the overwrite removed from the implementation the test fails; with it restored it passes.

Implemented

  • G4 — the door state reaches /api/status, the OLED and the dashboard, as three states rather than two: "open" and "shut" both presuppose a switch, and a kiln with none fitted must not read as one whose door is shut.
  • C5, C13 — tools/layercheck.sh. The core's platform-freedom is checked from the preprocessor's own record (via ninja -t deps), not from include lines, so an indirect or ESP_PLATFORM-guarded include is caught; no-mutable-state and no-clock-reads are checked from the object code, which also catches a function-local static. Plus static_asserts on the 20-byte log record, and a --self-test that proves each check can fail.
  • C7 — tools/logdump.py, with a decoder written from the format's documentation rather than from kiln_core, and a committed wire-format fixture both implementations are pinned to. A codec that encodes and decodes with the same wrong idea round-trips perfectly and proves nothing.
  • SWR-CFG-09 — a factory reset that erases rather than delists, reachable from the display, refused mid-firing, reporting counts.
  • SWR-LOG-16 — security events and the owner's opt-out. The update-path events wait on H2: a version string and a refusal reason do not fit a 20-byte record.
  • I3 — apparent power stays apparent, with the cost stated rather than implied.

Closed as already done

L6 (the hardware build has been in CI since its first build turned up two -Wformat-truncation errors), N1 (the access point whose missing passphrase was the live CRA weakness is gone, so the surface is deleted rather than regulated), J1/J2, M4.

Verification

26/26 host suites, the same under ASan/UBSan, kiln_core line coverage 91.8 % against the 90 % floor, clang-tidy 20 clean on host and target, layercheck clean, 57 logdump self-test checks, both IDF configurations build (hardware image 54 % free), StrictDoc exports clean.

…cut off

Two defects in one table, each of which the tasklist suspected and neither of
which anything checked.

**31 German causes contained characters the font cannot draw.** The 5x7 font
in kiln_hmi carries the 95 printable ASCII glyphs and nothing else. The
labels had always been transliterated -- VERSCHWEISST, KEINE WAERME -- and the
long causes had not: 68 non-ASCII characters, every one reaching the panel as
a blank. A German operator read a fault screen with holes in the instruction.
All of them are transliterated now, and a test holds the whole table to
0x20-0x7E so the next umlaut fails the build.

**27 causes were longer than the screen can show.** They run to 279
characters against five rows of 21, and what falls off the bottom of a 128x64
panel is the end of the sentence. In an instruction the end of the sentence is
the instruction: for KILN_FAULT_CONTACTOR_WELDED the dropped part was
"ISOLATE THE KILN AT ITS SUPPLY NOW", and for the supervisor's reasons it was
"press the supervisor's clear button on the panel" -- the one piece of
information distinguishing the two acknowledgements this system has.

Shortening the causes would have been the wrong fix: the same strings are what
/api/status sends, and a browser renders a paragraph well. So each entry may
carry a panel form as well -- same meaning, action first, trimmed to what the
screen holds -- and carries none where the full cause already fits, so there
is no second string to drift from the first. kiln_fault_panel_in and its two
siblings fall back to the cause, so the display asks for one thing and always
gets something it can render completely.

Two gates, both written to name the offender rather than merely fail, because
a bound that does not say which string is a bound nobody can fix:

  - every panel form fits five rows under the screen's own word wrap;
  - no word is longer than a row. That is a real constraint in German --
    "Thermoelement-Messeingang" is 25 characters -- and draw.cpp's own comment
    says why cutting one is unacceptable: a word split in a hurry reads as a
    different word.

Writing the short forms turned up a misassignment worth noting: the text about
current disagreeing with the commanded state belongs to KILN_WARN_RELAY_SUSPECT,
not to KILN_WARN_CURRENT_DEV, which is about drift from the run's reference.
Two different warnings with two different remedies.

26/26 host suites, the same under ASan/UBSan, clang-tidy 20 clean.
G4. The safety cycle has read the interlock pin every 100 ms since
SWR-SAF-31 was implemented and shown it nowhere, so an operator standing in
front of an idle kiln could see that HEAT was absent and had to guess why.

It is published in the snapshot, taken from the input the rule actually used
rather than re-read from the port: a second sample of a safety input at a
different instant is how two parts of one device come to disagree about
whether the door is open.

Three states and not two, in all three places. "open" and "shut" both
presuppose a switch, and a kiln with no interlock fitted must not read as one
whose door is shut -- that is precisely the case warning 113 exists for and
the one wrong answer available here. So:

  /api/status  carries `door` as a string, not a boolean, so no client can
               accidentally treat an unmonitored door as a safe one.
  the panel    spells out all three on the diagnostics screen, and puts DOOR
               in the slot that would say HEAT. The two cannot both be true,
               so the badge that says why the kiln is hot says why it is not.
  the browser  raises a pill only for "open". "unmonitored" is a standing
               condition with warning 113 already in the banner, and
               repeating it on every idle kiln without an interlock would
               train the operator to ignore the row.

Making room on the diagnostics screen cost the power and energy row labels,
which now share one row with the units doing the naming -- kW is a rate and
kWh is a total, a distinction a kiln owner makes before a firmware engineer
does. The two strings left the table rather than staying in it unused.

Four tests, including that an unmonitored door never renders as a shut one on
the panel, and that the API reports all three states as the simulator's
switch is opened, closed and removed.

Also closed, both already true: L6, the hardware build has been in CI since
its first build turned up two -Wformat-truncation errors; and N1, whose live
weakness was an access point that would start without a passphrase -- the
access point is gone, so the surface is deleted rather than regulated, which
is the only kind of fix that cannot be misconfigured back. N4's example of a
client joining that access point is reworded with it.

26/26 host suites, the same under ASan/UBSan, clang-tidy 20 clean on host and
target, hardware image builds, StrictDoc exports clean.
C5 and C13. Three architecture decisions held by discipline alone, and the
grep standing in for the first of them said so in its own comment.

**SWA-14, the core includes no platform header.** A grep over include lines
misses the only two ways this has a real chance of going wrong: a platform
header reached indirectly through a project header, and one inside
`#if defined(ESP_PLATFORM)`, which the host build never compiles -- so the
core could stop being portable while every gate stayed green. The check now
reads the preprocessor's own record of every header it opened, from the
target build where those headers are on the path and a mistake would
actually resolve. Ninja keeps that in a binary log rather than in .d files,
so it comes from `ninja -t deps`: 202 headers on the current build, none of
them an IDF component the core may not reach. The direct grep stays as well,
because that is what a reviewer means by the question.

**SWA-03, no mutable state in the core, and SWA-02, time is injected**, are
checked from the object code, which is stronger than any grep over sources.
A mutable file-scope object lands in .data or .bss, so nm finds it whatever
it is called and wherever it was declared -- including a function-local
static, which is the form that gets past a reviewer looking for globals. A
direct clock read leaves an undefined symbol for time, clock_gettime,
esp_timer_get_time and the rest. Both hold today: zero writable symbols and
zero clock references in libkiln_core.a.

What those two buy is the 168-hour firing a test exercises in milliseconds
and the 15-minute runaway timer it does the same to. Both would have broken
silently: the tests keep passing, they just stop testing what they claim to.

**SWA-18, the 20-byte log record**, gains static_asserts beside the encoder:
the size, the CRC as its last byte, and architecture 10.4's 204 records per
sector following from it. It guards a persisted format, so changing the
number without changing the sector header's format_version would make every
log already written decode as plausible nonsense -- plausible because the CRC
would be recomputed over whatever the new layout produced.

The component graph is checked too, from each component's REQUIRES, for
layering against architecture section 4 and for cycles: seven components,
layered and acyclic.

The script carries a --self-test that builds a deliberate global, a
function-local static and a time() call and proves each check sees them,
because a gate nobody has seen fail is a gate nobody should trust. It also
says which checks it skipped rather than reporting a pass it did not make, so
CI runs the object-code half in the layering job and the include half in the
hardware-build job, where each has what it needs.

26/26 host suites, layercheck clean, 4/4 self-test checks.
C7. Decodes a kilnlog partition dump to CSV, walking the ring in sequence
order rather than address order so a wrap does not interleave two firings,
and reads what esptool read_flash produces -- which needs no cooperation from
a device that will not boot, this being exactly when a log matters.

It would have been less code to link kiln_core and call kiln_logrec_decode.
That is precisely what it must not do. A log is the only evidence of what a
kiln did before it failed, and the question an investigator has is "did the
firmware record this correctly" -- which the firmware's own decoder cannot
answer, because a codec that encodes and decodes with the same wrong idea
round-trips perfectly and tells you nothing.

So the decoder is written from the format as logrec.h and architecture 10.2
document it: offsets, scales, CRC-8 poly 0x07 and CRC-16/CCITT-FALSE, the
state nibble and the flags above it. The honest cost is stated in its header:
the format now lives in two places and the two can drift.

Which is what the fixture is for. firmware/controller/test/host/fixtures/
logring.bin is two log sectors the C++ encoder produced, committed, with
three records chosen for what a decoder gets wrong -- a negative temperature,
the segment sentinel, every flag at once, a non-sample event, each field at
the edge of its scale -- and the sectors deliberately out of address order so
that anything reading the ring by address is caught.

Both implementations are pinned to it. test_logrec re-encodes the same
samples and compares bytes, reporting the first differing offset rather than
"the images differ", because an offset sends the reader to a field and a
hex dump sends them nowhere. logdump's self-test decodes the same file and
checks every field of all three records. The fixture's README says what to do
when it fails: ask which side moved, and if the format changed deliberately
then KILN_LOG_FORMAT_VERSION changes with it so logs already on devices stay
readable.

55 self-test checks, run in CI beside the other tool self-tests. 26/26 host
suites, clang-tidy 20 clean.
… than NVS

SWR-CFG-09 and SEC-14, from tasklist N4. The owner can now erase everything
from the device itself: the configuration and the WiFi credentials inside it,
the latched fault, every run record, the sample log and the wear counters. It
restarts the run numbering, because a kiln whose first firing is number 94
tells its new owner something false about its history, and it leaves the
production data block and the firmware alone -- those identify the unit for
its whole life and are not the owner's data.

Last entry of the display's menu, behind the confirmation an abort uses and
defaulting to "no", because the knob's first position must not be the
destructive one. Refused while running, paused or autotuning. It reports
counts rather than only success and does as much as it can rather than
stopping at the first failure: a reset that gave up half way would leave an
owner with some of their data erased and no idea which.

The implementation overwrites the credential with zeros and commits BEFORE
erasing the key, because on NVS an erase delists an entry and leaves its bytes
in the page until a garbage collection that may never come -- and "the API
says it is not configured" is not the question somebody selling a kiln is
asking.

Which is the part worth reading. The requirement says the test must read the
raw store back and look for the secret rather than asking the API, and it was
right to be that specific: the first version of this test passed with the
overwrite deleted. Twice, for two different reasons, both of them the host
key/value fake being kinder than the medium it stands in for:

  - erase() memset the whole entry, so the bytes went whether or not anything
    had overwritten them;
  - set() then reused the delisted slot, so the next write destroyed the
    evidence even after that was fixed.

The fake now models NVS: an erase delists the entry and keeps its bytes, a
write never lands on a delisted slot, and collection happens only when a test
asks for it. The flash fake has enforced NOR's refusal to un-clear a bit for
the same reason -- a fake that cannot reproduce the failure cannot be used to
prove it does not happen.

With the overwrite removed from kiln_app_factory_reset the test now fails with
"it was delisted rather than erased"; with it restored it passes. That
experiment is the evidence, and the test says so beside the assertion.

Three tests: the raw-bytes check including across a reboot, the history and
counters going with it, and the refusal mid-firing leaving a report that does
not read like a partial success.

26/26 host suites, the same under ASan/UBSan, clang-tidy 20 clean, hardware
image builds, StrictDoc exports clean.
…d to vanish

SWR-LOG-16, the half that does not depend on the update path, plus a defect it
exposed in the log ring.

Three event codes are appended to the persisted log -- an abnormal reset, a
factory reset, a network join commanded at the display -- and
log.security_events is the owner's opt-out, default on, because the default
has to be the one that helps an owner who has not thought about it.
UR-REG-004 asks for that opt-out as explicitly as it asks for the log: a
device that records access to itself regardless of its owner's wishes is a
surveillance feature rather than a security one.

The opt-out is applied in the one place every event passes through, so no
emitter can forget it, and it removes only the security events. A fault, a
state change and the periodic sample are the evidence of what a kiln did,
which is a safety record rather than a record of access to the device, and no
configuration item switches those off.

Details worth knowing: a reset cause the adapter cannot identify counts as
abnormal, because a reset nobody can account for is exactly what a log should
carry and calling UNKNOWN normal would hide an adapter that is wrong. The
factory reset logs itself last, so the record is the first entry of the new
owner's log and explains why the rest is missing. The join records no SSID --
a log of which network a kiln joined is a log of where it lives.

What is NOT here, and why: the update-path events this requirement lists need
a version string and a refusal reason, and neither fits a 20-byte record
(SWA-18). They arrive with the update path (H2) and will need a second record
type or their own channel. Codes that could carry no detail would have looked
like progress and recorded nothing.

**The defect this found.** A record appended outside a run was lost. Until
these events existed nothing ever appended outside one: every emitter ran
inside a firing and begin_run had always claimed a sector first. A boot-time
event went into a sector with no header -- which iterate() skips, because it
selects runs from headers, and which the next begin_run() erases. Written,
stored, counted, lost.

The ring now claims a sector lazily, tagged run 0 for "not part of a run",
which costs no extra erase: on a fresh ring it claims the sector begin_run
would have claimed, and on a device with history it never fires. The
bookkeeping was the part that bit. claim_sector writes a header and nothing
else, so the first version left head_seq at 0; begin_run then issued the SAME
sequence number to the run's sector, two sectors tied for highest, and since
mount resolves order from the sequence number alone (architecture 10.3) the
tie made an idle record the newest in the ring. SWA-09's power-loss journal
then read the wrong record, and three recovery tests failed -- which is how I
found it rather than shipping it.

A test now covers the property directly, including the sequence numbers not
tying, and logdump learned the three new names while asserting the old codes
still mean what every existing log says they mean.

Config schema 3: the opt-out is appended at the END of the struct, which is
the case SWR-CFG-05's migration was designed for -- an older blob is shorter,
the prefix copies, the new item takes its default. Schema 2's mid-struct
removal needed a splice; this needs nothing.

26/26 host suites, the same under ASan/UBSan, 57 logdump checks, layercheck
clean, clang-tidy 20 clean on host and target, hardware image builds,
StrictDoc exports clean.
…tasks

**A hardware branch at level 3.** SYS-HW-* says what the system needs: two
switching devices in series, a buzzer, an interlock input. What the board has
to achieve to deliver that lived in 22 task-list entries, which is a list of
fixes rather than a specification: it said what was wrong today and not what
right looks like.

docs/03_hardware_req.sdoc is HWR-01 to HWR-23, each refining a SYS-HW-* or
SYS-SAF-*, with the numbers and the verification method. The rule for what
belongs there: a requirement is a hardware requirement when getting it wrong
is a board change rather than a firmware one. The V-model's level table gains
the branch, which was the classic V all along and merely implicit here.

Content worth knowing it now carries: the charge pump's 1 s release with the
two numbers that need establishing rather than assuming (a pumped level of
about 2.7 V against a MOSFET characterised at 4.5 V, and a 155 ms decay that
is a slide rather than an edge); the CT's full scale, anti-alias corner below
Nyquist, open-input bias and clamp; separate SPI buses for the two front ends,
because a supervisor whose chip select a crashed controller can drive is not
independent of it.

**The task list is a third of its length.** 1173 lines to 313, 10 979 words to
2 451. Every completed item is gone -- what was done and why is in the commit
that did it, which is what the file's own header has always said -- and the
remaining 49 are one to four lines each. All 49 identifiers are preserved,
since they are cited from commit messages.

Section A now points at the HWR requirements rather than repeating their
numbers, so the board's specification has one home. The state table is
re-measured and the stale rows are gone: it claimed the hardware
configuration was not in CI and that the phase strap and extra CT inputs were
missing against requirements that no longer exist.
My own gate failed CI on correct code, which is the worst way for a gate to
behave: the next person deletes it.

SWA-03 was read from nm's one-letter class, where 'd' means initialised
writable data. A `const` table of POINTERS has to have its pointers relocated
at load time, so on ELF it lands in .data.rel.ro -- and nm calls that 'd' too.
On Mach-O such tables stay in __const and get 's', which is why this passed
locally and flagged eight legitimate tables in kiln_core the moment it ran on
Linux: k_items, k_examples, k_sup_reasons and the five name tables.

Now read from the SECTION rather than the class. GNU nm's sysv format carries
a Section column; Apple's leaves it empty, so the Mach-O branch asks `nm -m`
instead. Both reject .data, .bss, their thread-local forms, a C `*COM*` common
symbol, and a bare .data.rel -- and neither rejects .data.rel.ro, which is the
whole point. The sections are named rather than pattern-matched, so one nobody
anticipated is reported instead of silently allowed.

The self-test gains the case that would have caught this: a const table of
pointers must NOT be reported as mutable state. It also now calls
writable_syms itself instead of a copy of the nm invocation -- duplicating the
command under test is exactly why the first self-test could not see the
false positive, which is a lesson worth more than the fix.
The licence-hygiene job checks every tracked .sdoc, and the new one carried
no header: StrictDoc files cannot take a leading comment, so the other
documents put theirs in the METADATA block and this one now does too.

Its OPTIONS block is also brought into line with the rest. StrictDoc's
grammar fixes the order of those keys, and REQUIREMENT_STYLE after
NODE_IN_TOC is a parse error rather than an ignored field.
@Boernsman

Copy link
Copy Markdown
Contributor Author

Landed on main as 54a66cb by rebase, outside the PR merge button: gh pr merge is refused for this PR because it touches .github/workflows/ci.yml and the CLI's token lacks the workflow scope. The commits on main are these nine rebased, verified before the push (26/26 suites, layercheck 5/5, StrictDoc clean). Closing as merged-by-other-means.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant