Skip to content

fix(profiles): accept a limiter with no range, and refuse one with no value - #795

Open
ChampionDesigns wants to merge 5 commits into
decentespresso:mainfrom
ChampionDesigns:ben/profile-limiter-parsing
Open

fix(profiles): accept a limiter with no range, and refuse one with no value#795
ChampionDesigns wants to merge 5 commits into
decentespresso:mainfrom
ChampionDesigns:ben/profile-limiter-parsing

Conversation

@ChampionDesigns

Copy link
Copy Markdown
Collaborator

Summary

StepLimiter.fromJson defaulted a missing value, which turns a garbage limiter into a silent
OFF on the machine
. It now refuses visibly — a FormatException, which the REST boundary reports
as a 400.

range becomes genuinely optional. Absent or null means a hard cap at value, encoded as
range 0 — which is the DE1 wire's own encoding, and what a client arming a cap actually sends.

Base: main. Independent.

Design notes

value is required and range is not, deliberately: the same policy TransitionType.hold and
ExitType.power already follow above it in the same file. A limiter with no value is not a limiter.

A rangeless limiter hashes identically to one with an explicit range: 0, so profile identity is
unchanged for anything already stored.

Two tests were deliberately left out. They carried the limiter on a ProfileStepPower, which
is added by the profile-modes PR and does not exist on main. One duplicated the flow-step test
beside it; the other tested the power step's mandatory-limiter rule, not the limiter parser.
They should be re-added once both PRs land.

Linked Issue

N/A

Verification

  • flutter analyze — clean.

  • flutter testfull suite 3914 passed / 1 skipped, run against current main on
    5 Sep 2026.

  • flutter teststep_limiter_test 18 passed; test/models and test/services/webserver
    506 passed.

  • dart format — clean on every changed file.

  • Fixtures test/fixtures/f048/e01_post_body.json and e01_put_body.json pin the REST shape.

  • Verified on hardware. This change ships in the Decaid-Canary build Ben runs on his own
    machine, and has been exercised in normal use rather than only under test.

Impact

  • API: a malformed limiter is now a 400 where it was silently accepted and disabled the
    limiter. assets/api/rest_v1.yml documents the rule.
  • Compatibility: a client sending {"value": x} with no range now gets a hard cap, which is
    what it meant. A client sending a limiter with no value now gets a 400 — that request never did
    what it looked like.
  • User-visible: a profile with a malformed limiter is refused at upload instead of running with
    the limiter off.
  • Security: none.

Contributor Responsibility

AI-assisted development is allowed. The submitter remains responsible for the submitted work.

  • I have reviewed and understand all changes in this PR and take responsibility for their correctness, security, behavior, licensing, and provenance, including any AI-assisted or AI-generated work.

ChampionDesigns and others added 2 commits September 5, 2026 06:38
…e way on POST and PUT

Decal audit finding F-048, server half. A client arming a step limiter from
null sends `{"value": x}` with no range, and `StepLimiter.fromJson` read both
keys through `parseDouble`, which throws on null. POST answered 500 and PUT
answered 400 for the same bytes, because only `_handleUpdate` carried an
`on TypeError` arm.

Measured on the device, 29 Aug (captures kept verbatim as the two fixtures):

  POST /api/v1/profiles       -> 500 {"error":"Internal server error", ...}
  PUT  /api/v1/profiles/<id>  -> 400 {"error":"Invalid request", ...}

both with the message `type 'Null' is not a subtype of type 'String'`, neither
naming the limiter.

`range` now defaults to 0.0 when absent or null, because range 0 is the DE1
wire's own encoding of "no falloff band" — every encoder writes
`data[2] = _encodeFlowPressure(limiter.range)` and the mock's `range <= 0`
branch already hard-clamps to `value` — so a rangeless limiter parses to
exactly what the user set, a hard cap, rather than to a band the server
invented.

`value` stays required and is now refused BY NAME, as is a garbage `range`:
defaulting a missing value to 0 would turn a malformed limiter into a silently
OFF one on the machine, which is the same silent degradation this file already
refuses for TransitionType.hold and ExitType.power. The new `on TypeError` arm
in `_handleCreate` is byte-identical to `_handleUpdate`'s and catches the
residual cases (a null transition, a non-string name), so the two routes now
answer identically for every malformed profile body.

`rest_v1.yml` moves `range` out of StepLimiter's required list and documents
the default. The request contract genuinely changed, and the spec is what the
next agent generates clients from; it stays required in practice on the way
out, because an omitted range is stored as 0 and every response carries it.

No profile id moves. The hash is computed on the parsed model, and the only
bodies whose parse changes are ones that previously threw and so never had an
id; `{value: x}` and `{value: x, range: 0}` now hash equal, which is correct
dedupe for identical machine behaviour. Two characterisation tests pin the
pre-fix hashes of a complete-limiter and a null-limiter profile and both still
pass unchanged.

Tests first, confirmed red: the fixtures reproduced the device's 500/400 split
before the fix. 3697 pass, 1 failure — `workflow_handler_test.dart`
"publishes each changed flow exactly once", the known wall-clock flake, which
passes 40/40 twice when run alone and shares no code with this change.
flutter analyze clean, dart format clean.

The sb-dev smoke was NOT run, deliberately: booting the app mounts
`device_discovery_view`, whose initState calls `connectionManager.connect()`,
and the desktop serial service probes USB ports on scan. Ben's machine is on
/dev/ttyACM0, so a smoke run would have opened the real hardware. The handler
tests cover the same surface with the real controller, handler and router.

Upstream carries the identical fault, so this is PR-able to Vid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This lane is re-cut onto upstream/main so it can be a PR. Two of its tests
carried the limiter on a ProfileStepPower, which is ben/profile-modes' own
addition -- upstream's ProfileStep.fromJson refuses it with "Invalid step
type. Must include either pressure or flow."

The production change is in StepLimiter.fromJson and is independent of step
type, so nothing is lost:

- "a power step accepts one as a hard cap" duplicated the flow-step test
  directly above it, which stays.
- "a power step still refuses a zero-value limiter" tested the POWER step's
  own mandatory-limiter rule, not the limiter parser. That behaviour belongs
  on ben/profile-modes and both tests should be re-added there.

powerStepJson is removed with them; it had no other caller on this lane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@tadelv tadelv left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two required fixes before merge:

  1. The OpenAPI contract says an explicit range: null is accepted, but in OpenAPI 3.0.3 an optional numeric property is still non-nullable unless nullable: true is set. Please make the schema match the implemented/request-tested behavior.
  2. Current AGENTS.md says not to add explanatory comments to new/substantially rewritten code and to keep rationale/hardware/debug history in the matching doc/AI_*_NOTES.md. Please remove/move the new explanatory/debug-history comments (including the test-file header where applicable) while keeping the code/tests self-explanatory.

The behavioral direction otherwise looks sound: value-only limiters canonicalize to range 0, valueless limiters fail visibly, POST/PUT behavior is aligned, hash compatibility is covered, and CI is green.

Comment thread assets/api/rest_v1.yml
format: double
description: Acceptable range around the limiter value
default: 0
description: >-

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The description says explicit null is accepted, and the parser/tests do accept it, but this file is OpenAPI 3.0.3. Removing range from required only permits omission; it does not permit JSON null for a type: number property. Please add nullable: true (or stop documenting/accepting null) so the published contract matches runtime behavior.

Comment thread lib/src/models/data/profile.dart Outdated
value: parseDouble(json["value"]),
range: parseDouble(json["range"]),
);
// `value` is required: defaulting it would turn a garbage limiter into a

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current AGENTS.md explicitly says not to add explanatory comments to new/substantially rewritten code and to put rationale, hardware constraints, and debugging history in the matching doc/AI_*_NOTES.md. Please keep the validation readable through names/control flow and move/remove this rationale; the same guidance applies to the long explanatory block immediately below and the new test-file header.

Two review points.

The published contract now matches the parser. In OpenAPI 3.0.3 dropping `range`
from `required` permits omission only, so a `type: number` property still refuses
an explicit null however the description reads. `nullable: true` says what the
parser actually does.

The explanatory comments this lane added to StepLimiter.fromJson, the test-file
header and the handler-test group are gone, and the reasoning is in
doc/AI_API_NOTES.md under "Profile Step Limiters" — where AGENTS.md puts
rationale. The names and the control flow carry the meaning on their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@tadelv tadelv left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two previous blockers are resolved: the OpenAPI schema now permits explicit null via nullable: true, and the implementation/test rationale was moved out of code into doc/AI_API_NOTES.md. Current CI is green.

One small documentation fix remains before merge: the new API note says POST /api/v1/profile, but the implemented endpoint is POST /api/v1/profiles. Please correct that path. I did not find any other merge-blocking issues in the updated diff.

Comment thread doc/AI_API_NOTES.md Outdated
property from `required` permits omission only; without `nullable` the published
contract would still refuse the explicit null the parser accepts.

`POST /api/v1/profile` used to answer such a body with a 500 while `PUT` answered

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint path is singular here, but the actual route is POST /api/v1/profiles. Please correct the note so the API guidance does not point readers at a nonexistent endpoint.

ChampionDesigns and others added 2 commits September 10, 2026 15:44
The endpoint is POST /api/v1/profiles, not /api/v1/profile, and the update path
it is compared against is PUT /api/v1/profiles/{id}. A note whose job is to stop
drift must not point at a route that does not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ChampionDesigns

Copy link
Copy Markdown
Collaborator Author

The path is corrected in 416574cfPOST /api/v1/profiles, and the update path it is compared against is PUT /api/v1/profiles/{id}. Thank you for catching it; I wrote that note and got it wrong. I checked the rest of the tree for the same slip and there is no other singular /api/v1/profile in any doc, scenario or spec.

Also brought current in c05e2b99main moved under this branch while it waited. The conflict was a pure add/add in doc/AI_API_NOTES.md: main's new "Fixed Port Already Bound" section and this branch's "Profile Step Limiters" section land at the same anchor. Both are kept, and I verified byte for byte that neither lost a line — 62 from main, 21 from here.

As on the other branches I merged rather than rebased: this branch is published and reviewed, and my pre-push hook refuses a non-fast-forward. The three-dot diff is unchanged and still only this branch's own work.

Verified after the merge: flutter analyze clean, dart format clean on Dart 3.13.3, and the limiter tests pass — 32 across step_limiter_test.dart and profile_handler_create_test.dart.

tadelv commented Sep 10, 2026

Copy link
Copy Markdown
Member

Reopening this because I think the remaining question needs to be answered explicitly on the Bengle/v2 firmware side rather than inferred from MockDe1.

The PR currently treats a value-only limiter as { "value": x, "range": 0 } and documents range == 0 as a hard cap/no-falloff representation. That does not match the legacy DE1 firmware path I checked: there, a zero range causes the extension-frame limiter setup to be skipped rather than installing a no-falloff clamp.

Since Decaid serializes the limiter range into the extension frame, while MockDe1._applyLimiter() currently interprets range <= 0 as a literal hard clamp, we can otherwise end up with tests that validate Decaid's own assumption rather than the physical machine's behavior.

Could we clarify the intended/actual semantics for v2 profiles on Bengle firmware and make those authoritative here? In particular:

  • What should Bengle do for a limiter with value > 0 and range == 0?
  • Is there a supported v2 wire representation for a true hard cap / no falloff? If so, what is it?
  • Should a value-only JSON limiter be translated to that representation, or should it be rejected until such a representation is explicitly defined?

Once that is established, I think we should make the encoder, MockDe1, regression tests, and profile documentation all use the same semantics. Ideally the firmware behavior should also be explicit/consistent rather than relying on different interpretations of range == 0 across legacy DE1, Bengle, and Decaid.

I still agree with the useful part of this PR: a limiter containing range but no value should be rejected. The blocker is specifically canonicalizing { "value": x } to range: 0 while calling that a hard cap without first establishing that this is the Bengle v2 firmware contract.

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.

2 participants