FEAT-0007: Manifest-Backed Dashboard Feeds
Header
| Field | Value |
|---|---|
| ID | FEAT-0007 |
| Status | Proposed |
| Package | control-api (app/models/, app/schemas/, app/repositories/, app/services/) |
| Owner | @virorum |
| Created | 2026-08-01 |
| Updated | 2026-08-01 |
1. Summary
For each
Dashboard, maintain a manifest tagdashboard-{dashboard_id}on every sensor the dashboard actually reads, kept in sync as the dashboard is created, edited, and deleted. This lets a future control-api-ui change subscribe to oneGET /api/manifests/dashboard-{id}/eventsSSE stream per dashboard instead of polling each panel’s sensors independently.
2. Context & Motivation
#265. FEAT-0005 gave control-api-ui a persisted, multi-dashboard
store, but layout is an intentionally opaque, unvalidated JSON blob (FEAT-0005 §3,
“Out of Scope” — “Validating the internal shape of layout”). control-api’s existing
manifest system (ManifestHub, GET /api/manifests/{tag} //events) already does
exactly the kind of tag-scoped live feed control-api-ui would want per dashboard — but
nothing currently populates a dashboard-{id} tag on any sensor, and control-api-ui does
not currently subscribe to the manifest SSE endpoint at all (confirmed by inspection —
usePanelSeries/useHealthStatsPanel poll getSensor/sensorHistoryQueryOptions
directly via TanStack Query). This spec produces the tag machinery on the control-api
side; wiring control-api-ui’s panels to consume it is separate, future frontend work.
References:
app/runtime/manifest_hub.py,app/services/manifest_service.py,app/routers/manifests.py(existing tag-scoped manifest/SSE system this spec feeds)app/repositories/sensor_repo.py:123-126(get_sensors_with_tag, the JSONB.contains()query pattern this spec’s reconciliation reuses)app/services/sensor_service.py:42-84,131-174(upsert_sensor/_fan_out_upsert— precedent for both the reserved-tag-prefix guard and the “best-effort, must never fail the primary write” fan-out posture this spec copies)docs/specs/FEAT-0005-dashboard-store.md(theDashboardCRUD resource this spec extends; establishes the opaque-layoutcontract this spec deliberately does not touch)- control-api-ui
src/lib/dashboardLayout.ts,src/hooks/usePanelSeries.ts,src/hooks/useHealthStatsPanel.ts(confirms whylayoutcan’t be parsed server-side for this — see Design Decision below)
3. Scope
In Scope
- A new
sensorsfield onDashboard/DashboardCreate/DashboardRead/DashboardPatch: the explicit, client-supplied list of(device_id, sensor_ref, data_type)triples the dashboard’s current layout reads. See Design Decision. - Reconciling the
dashboard-{id}tag onapp.sensorsrows to matchsensorswhenever a dashboard is created, hassensorschange viaPATCH, or is deleted. - Atomic, race-free tag add/remove at the repository layer (new
SensorRepository.add_tag_to_sensors/remove_tag_from_sensors), replacing the read-then-full-replace patternSensorPatch.tagsuses today for this one purpose. - A reserved
dashboard-tag-name prefix:SensorService.upsert_sensorpreserves a sensor’s existingdashboard-*tags when a caller’sPATCH .../sensors/...supplies atagslist, instead of letting it silently overwrite them (see FR-6). - Fanning newly-added/removed dashboard tags out to
ManifestHub, so an already-open SSE subscription todashboard-{id}sees the sensor appear/disappear without a reconnect — mirroringSensorService._fan_out_upsert. - One Alembic migration adding the
sensorscolumn toapp.dashboards.
Out of Scope
- Parsing control-api-ui’s
layoutblob server-side. Rejected explicitly — see Design Decision. - control-api-ui actually populating
sensorsor consuming the manifest SSE stream. Separate, tracked frontend work; this spec only makes the backend capability exist. - Reconciling tags when a not-yet-reporting sensor referenced by
sensorslater starts reporting. See NFR-3 — accepted limitation, self-heals on the dashboard’s next save. - Fully race-proofing the reserved-tag-prefix preserve against a concurrent dashboard-tag reconciliation on the same sensor. See NFR-4 — narrowed, not solved.
- Any endpoint or schema change to the manifest system itself.
dashboard-{id}is just a tag value;GET /api/manifests/{tag}and.../eventsalready handle any tag.
Design Decision: Client-Supplied sensors, Not Server-Side layout Parsing
The obvious-looking alternative — have control-api decode layout itself and derive
the sensor set — does not actually work cleanly, for one panel type specifically.
chart/gauge panels store series[].{deviceId,sensorRef} plus a panel-level
dataType, which would parse cleanly. But a health_stats panel stores only
healthStats: {bodyDeviceId?, heartRateDeviceId?} (confirmed in
serializeDashboardLayout) — its 7 sensor_refs are frontend constants
(BODY_SENSOR_REFS, HR_SENSOR_REF in useHealthStatsPanel.ts), and each role’s
data_type is discovered at render time from listDeviceSensors, not stored anywhere.
Reproducing that resolution server-side would mean duplicating a constant list that
lives in control-api-ui with no shared source of truth — a second copy that silently goes
stale (manifest quietly missing sensors, no error) the moment control-api-ui’s panel
schema evolves, exactly the kind of cross-repo coupling layout’s opacity (FEAT-0005)
was meant to avoid.
Decision: add an explicit sensors: [{device_id, sensor_ref, data_type}, ...]
field, supplied by the client alongside layout, not derived from it. control-api-ui
already resolves every panel’s sensors — including health_stats’s dynamic role
lookups — to issue its own polling queries (usePanelSeries, useHealthStatsPanel);
it can supply this list exactly, with no guessing on control-api’s side. This keeps
layout fully opaque (FEAT-0005’s contract, untouched) and has no version-drift
failure mode: sensors is a flat, stable shape independent of layout’s internal
schema. The frontend work this requires is not “extra” — control-api-ui does not consume
the manifest system at all today, so some frontend change is required regardless
before this feature has any observable effect.
4. Functional Requirements
- FR-1:
Dashboard/DashboardCreate/DashboardReadgainsensors: list[{ device_id: int, sensor_ref: str, data_type: str}], stored verbatim (deduplication happens implicitly at reconciliation time, not at storage time). - FR-2:
DashboardPatchgainssensors: list[...] | None. APATCHthat supplieslayoutMUST also supplysensors— rejected with422(schema-level,model_validator) iflayoutis present withoutsensors. This prevents a layout change from silently leaving the tag set stale. The reverse is allowed:sensorsmay be supplied alone (nolayout) — this is the recovery path NFR-3 relies on for a dashboard whose referenced sensor didn’t exist yet at save time. - FR-3: On
POST /api/dashboards/success, the tagdashboard-{id}MUST be added to every sensor insensors(order-independent; emptysensorsis valid — no tags applied). - FR-4: On
PATCH /api/dashboards/{id}success where the patch includessensors, the tagdashboard-{id}MUST end up applied to exactly the sensors in the newsensorslist: added to sensors newly present, removed from sensors no longer present, left untouched on sensors present in both. - FR-5: Reconciliation (FR-4) determines “currently tagged” by querying
get_sensors_with_tag(f"dashboard-{id}")at reconciliation time — not by diffing against the dashboard’s previoussensors/layoutvalue. This makes reconciliation idempotent and self-healing: any prior drift (e.g. NFR-3’s not-yet-reporting case) is corrected on the next save regardless of cause. - FR-6:
SensorService.upsert_sensorMUST treat thedashboard-prefix as reserved: when a caller’s patch includestags, any of the sensor’s existingdashboard-*tags are preserved (re-appended) even if absent from the caller’s list, and anydashboard-*entries in the caller’s supplied list are stripped (silently — not a validation error) before the write. A caller can never add or remove adashboard-*tag directly throughPATCH .../sensors/...; only dashboard reconciliation (FR-3–FR-5) may. - FR-6a: The prior-tags read FR-6 depends on (
old_tags,upsert_sensor) is already wrapped intry/except Exception: logfire.exception(...)so it can never break the upsert — but under FR-6, silently treating a failed read asold_tags = []would meanpreservedcomes back empty and the caller’s list wins outright, wiping everydashboard-*tag on that sensor: the exact outcome FR-6 exists to prevent. If that read fails, thetagsfield MUST be dropped from the patch entirely (every other field in the patch still applies) rather than proceeding with an emptypreservedset — a tags-edit that silently no-ops on a transient read failure is an acceptable, rare degradation; silently deleting dashboard membership is not. - FR-7: On
DELETE /api/dashboards/{id}success, the tagdashboard-{id}MUST be removed from every sensor currently carrying it (queried the same way as FR-5). Unlike FR-3/FR-4, this reconciliation MUST run before the dashboard row is deleted, not after (see NFR-3a) — the delete only proceeds once the untag step has been attempted. - FR-8: Tag add/remove (FR-3, FR-4, FR-7) MUST be atomic, idempotent DB-level
operations (JSONB
||/-onapp.sensors.tags, scoped byWHEREto rows not already in the target state) — not an application-level read-full-list-then-write cycle. This is simpler and self-evidently idempotent, not primarily a concurrency fix (see NFR-4 for the residual, accepted race). - FR-9: Every sensor actually changed by FR-3/FR-4/FR-7 (i.e. rows the
WHEREclause matched — not the full target/removal set, which may include no-ops) MUST be fanned out toManifestHub:publish_sensor_updatefor each newly-tagged sensor (using the post-addSensorRead, soManifestHubsees the new tag), andpublish_sensor_removed(sensor, ["dashboard-{id}"])for each newly-untagged one (the pre-removalSensorReadis sufficient — only its composite key is used). - FR-10: Reconciliation for create/update (FR-3, FR-4) MUST be best-effort: a
failure (DB error on the tag write, or a
ManifestHubfan-out error) MUST be logged vialogfire.exceptionand MUST NOT fail the request, whose dashboard row is already committed. MirrorsSensorService._fan_out_upsert’s posture. This relies on FR-5’s self-healing diff — a swallowed failure here is corrected on the dashboard’s next save. - FR-10a: Reconciliation for delete (FR-7) is deliberately not
best-effort in the same way, because FR-10’s self-healing argument doesn’t apply
once the dashboard row is gone (see FR-7, NFR-3a): the untag step MUST be attempted
and MUST succeed before the dashboard row is deleted; if it raises, the exception
propagates and the delete does not proceed (dashboard survives, request fails,
retryable). A
ManifestHubfan-out error during that untag step, however, stays best-effort/logged-only, same as create/update — only the tag write itself gates the delete.
5. Non-Functional Requirements
- NFR-1: Follow existing layering — reconciliation logic lives in
DashboardService, which gains aSensorRepositoryalongside its existingDashboardRepository(both constructed from the same injecteddb: AsyncSession, per this repo’s multi-repository DI convention —get_dashboard_servicealready takesdbdirectly, so this is additive, not a signature change). - NFR-2: Tag mutation SQL casts
app.sensors.tags(JSON) toJSONBper-query, mirroringget_sensors_with_tag’s existingcast(Sensor.tags, JSONB).contains(...)— the column itself stays plainJSON(FEAT-0005’s “noJSONBcolumns” convention is about column type, not query-time casts, which already exist in this file). - NFR-3: No reconciliation is attempted for a
sensorsentry with no matchingapp.sensorsrow (sensor hasn’t reported yet). Accepted limitation: that sensor simply doesn’t join the manifest until it exists, and there is no background sweep to catch it up automatically. Recovery is re-PATCHing the dashboard with (at least)sensors(FR-2 allowssensorsalone, withoutlayout) once the sensor has reported — FR-5’s live-diff design makes this a correct, ordinary reconciliation, not a special repair path. - NFR-3a: NFR-3’s “recovery is the next save” argument is exactly why FR-10’s
best-effort posture is safe for create/update but not for delete: there is no next
save for a dashboard that no longer exists, so a swallowed untag failure on delete
would orphan
dashboard-{id}tags permanently and leaveGET /api/manifests/dashboard-{id}serving a dead dashboard’s feed indefinitely. FR-7/FR-10a’s reconcile-before-delete, fail-the-request-on-error ordering exists specifically to avoid that. - NFR-4: FR-6’s read-merge-write (existing sensor’s
dashboard-*tags, merged with the caller’s non-dashboard-*tags, written as one replaced list) is not fully race-proof against aPATCH .../sensors/...tags-edit landing concurrently with a dashboard reconciliation on the same sensor — the reconciliation’s atomic add (FR-8) could land between the operator patch’s read and write and be overwritten. Accepted as a narrow, low-frequency edge case (this repo’s sensor ingest path never sendstags— seesensor_reading()— so the only real trigger is a human editing tags in the UI at the same moment a dashboard is saved); not solved by this spec. A future fix would need FR-6’s preserve step to also be a DB-level atomic operation rather than app-level merge. - NFR-5: No change to
GET /api/manifests/{tag}or.../events— both already accept any tag string;dashboard-{id}requires no new routing.
6. Interface Contract
DashboardSensorRef: { device_id: int, sensor_ref: str, data_type: str }
POST /api/dashboards/ body now requires `sensors: [DashboardSensorRef, ...]` alongside `name`/`layout` (may be `[]`)PATCH /api/dashboards/{id} `sensors` optional, but required together with `layout` (both-or-neither) -> 422 if only one givenDashboardRead gains `sensors: [DashboardSensorRef, ...]`
No new routes. Manifest access is the existing:GET /api/manifests/dashboard-{id} -> Manifest snapshotGET /api/manifests/dashboard-{id}/events -> SSE snapshot + delta stream7. Acceptance Criteria
- AC-1: Creating a dashboard with a non-empty
sensorslist results in every listed sensor carrying tagdashboard-{id}, verifiable viaGET /api/manifests/dashboard-{id}. - AC-2:
PATCHing a dashboard’ssensorsto a list that drops sensor A and adds sensor B results in A no longer carryingdashboard-{id}and B carrying it; sensors present before and after are untouched (their other tags, if any, unaffected). - AC-3:
PATCHsupplyinglayoutwithoutsensorsreturns422;PATCHsupplyingsensorswithoutlayoutsucceeds (FR-2’s one-directional constraint — this is NFR-3’s recovery path).POSTrequires bothnameandlayoutregardless (FEAT-0005, unchanged) plussensors(FR-1, defaults to[]if omitted rather than being required-and-absent-triggers-422, sinceDashboardAttrs.sensorshas adefault_factory). - AC-4: Deleting a dashboard removes
dashboard-{id}from every sensor that had it; the dashboard row itself is gone (existing FEAT-0005 behavior, unchanged). - AC-5: A
PATCH .../devices/{id}/sensors/{ref}/{type}supplyingtags: ["dashboard-3", "custom"]on a sensor whose existing tags includedashboard-3anddashboard-7results in stored tags["custom", "dashboard-3", "dashboard-7"](order not asserted) —dashboard-7preserved, the caller’s literaldashboard-3deduplicated against the preserved one,customapplied. - AC-6: Referencing a sensor in
sensorsthat has noapp.sensorsrow yet does not error the dashboard create/update; that entry is simply not tagged (NFR-3). - AC-7: An open SSE subscription to
dashboard-{id}receives adeltaevent adding a sensor within one flush interval of aPATCHthat adds it tosensors, and aremoved_sensor_idsentry when aPATCHremoves it — no reconnect required. - AC-8: A dashboard create/update still succeeds (
2xx) even if tag reconciliation raises internally (simulate via a monkeypatched repository method) — verifies FR-10’s best-effort posture. Conversely, a dashboard delete whose untag step raises does not delete the dashboard row — the request fails and the dashboard is still fetchable afterward (FR-10a, NFR-3a). - AC-8a: A sensor
PATCH .../sensors/...supplyingtagswhose prior-tags read fails (simulate via a monkeypatched repositoryget) still applies every non-tagsfield in the patch, and leaves the sensor’s existingdashboard-*tags untouched (FR-6a) — the patch does not silently wipe them. - AC-8b: Adding the
sensorsmigration column to a pre-populatedapp.dashboardstable (seed at least one row before running the migration in a test/staging apply) succeeds without a manual backfill step (§8’sserver_defaultrequirement). - AC-9:
uv run pytest tests/green;black .clean.
8. Data & State Changes
- Schema:
app.dashboardsgainssensors—JSON,nullable=False,default=list, matchinglayout’s column shape. - Migration: new Alembic revision (next available id after the current head at
implementation time — re-verify head immediately before creating; do not assume
20260801_01_add_sensor_cadence.pyis still the head).app.dashboardsis a populated table by this point (FEAT-0005 shipped), unlike20260730_01_add_dashboards.py(which created the table fresh) — a plainnullable=Falseadd_columnwith only a Python-sidedefault=listfails against existing rows, since ORM-level defaults don’t backfill. Follow20260801_01_add_sensor_cadence.py’scadence_samplescolumn instead:op.add_column("dashboards", sa.Column("sensors", sa.JSON(), nullable=False, server_default=sa.text("'[]'::json")), schema=_schema)— a real DB-side default, applied to existing rows at add-time. - No migration needed for
app.sensors—tagsalready exists (FEAT-0005-era); this spec only changes how it’s written for thedashboard-*subset, via new repository methods, not a new column.
9. Implementation Guidance
app/schemas/dashboards.py:- Add
DashboardSensorRef(UTCBaseModel):device_id: int,sensor_ref: str,data_type: str,extra: "forbid". (A localBaseModel, not the trigger subsystem’sSensorRefJSONTypedDictinapp/trigger_expr/types.py— that type is internal to the trigger-expression tree, not a public API schema; this resource follows this file’s own existing pattern instead.) - Add
sensors: list[DashboardSensorRef] = Field(default_factory=list, ...)toDashboardAttrs(coversCreate/CreateInternal/Readvia inheritance). - Add
sensors: list[DashboardSensorRef] | None = Field(None, ...)toDashboardPatchandDashboardPatchInternal. - Add a
model_validator(mode="after")onDashboardPatchenforcing FR-2 (("layout" in self.model_fields_set) == ("sensors" in self.model_fields_set)).
- Add
app/models/dashboard.py— addsensors: Mapped[list[dict]](JSON,nullable=False,default=list), same shape aslayout.app/repositories/protocols.py(SensorRepository) +app/repositories/sensor_repo.py— two new methods:add_tag_to_sensors(keys: list[SensorId], tag: str) -> list[SensorRead]: singleUPDATE ... WHERE (device_id, sensor_ref, data_type) IN (...) AND NOT (tags::jsonb @> :tag_json) SET tags = (tags::jsonb || :tag_json)::json RETURNING *(via SQLAlchemy Coreupdate().returning()); emptykeysshort- circuits to[]without a query.remove_tag_from_sensors(keys: list[SensorId], tag: str) -> list[SensorRead]: same shape,WHERE tags::jsonb @> :tag_json,SET tags = (tags::jsonb - :tag)::json.- Both reuse the
tuple_(...).in_(...)pattern already imported in this file (get_tags_for_sensor_keys) for the key filter.
app/services/dashboard_tags.py(new, small) —DASHBOARD_TAG_PREFIX = "dashboard-"anddef dashboard_tag(dashboard_id: int) -> str. Imported by bothDashboardService(FR-3/4/7) andSensorService(FR-6) so the literal prefix exists in exactly one place.app/services/sensor_service.py(upsert_sensor) — FR-6/FR-6a: the existingold_tagsread is already inside atry/except Exception: logfire.exception(...); old_tags = []— add an explicit flag (e.g.old_tags, tags_read_ok = [...], True/Falseon the except branch) rather than reusing the empty-list fallback as a signal. When"tags" in patch_fields: iftags_read_ok, computepreserved = [t for t in old_tags if t.startswith(DASHBOARD_TAG_PREFIX)]andcaller_tags = [t for t in patch_fields["tags"] if not t.startswith(DASHBOARD_TAG_PREFIX)], and setpatch_fields["tags"] = sorted(set(preserved) | set(caller_tags))(note: this dedupes and reorders the caller’s entire tags list, not just thedashboard-*subset — an intentional, minor behavior change from today’s verbatim passthrough, not just a dashboard-scoped tweak). If nottags_read_ok, drop"tags"frompatch_fieldsentirely before callingself.repo.upsert(FR-6a) — every other field in the patch still applies.app/services/dashboard_service.py:- Constructor takes
sensor_repo: SensorRepositoryalongside the existingdashboard_repo. - Add
async def _reconcile_tags(self, dashboard_id: int, target: list[ DashboardSensorRef]) -> Noneimplementing FR-4/FR-5/FR-9 (diff againstget_sensors_with_tag, calladd_tag_to_sensors/remove_tag_from_sensors, fan out viaget_manifest_hub()). create_dashboard/update_dashboard(only if"sensors" in update.model_fields_set) call_reconcile_tagswrapped in a broadtry/except Exception: logfire.exception(...)per FR-10 — never re-raised, called after the row is committed.delete_dashboardcalls_reconcile_tags(dashboard_id, [])unwrapped (FR-7/FR-10a — the tag-write portion must propagate) before callingself._dashboard_repo.delete(dashboard_id); only theManifestHubfan-out portion inside_reconcile_tagsstays try/except-guarded even on this path (FR-10a).
- Constructor takes
app/dependencies.py—get_dashboard_servicealready takesdb: AsyncSession = Depends(get_db)directly (FEAT-0005’s shape); just addSQLAlchemySensorRepository(db)as a second constructor argument. No signature change needed elsewhere.- Alembic — new revision file adding the
sensorscolumn (§8). - Tests:
tests/unit/repositories/test_sensor_repo.py(or integration, since this needs real Postgres JSONB operators) —add_tag_to_sensors/remove_tag_from_sensorsidempotency and theRETURNING-only-changed-rows behavior.tests/unit/services/test_sensor_service.py— FR-6/AC-5 (preserve + strip + dedupe).tests/unit/services/test_dashboard_service.py— FR-3/4/5/7/10 (mock both repos; assertadd_tag_to_sensors/remove_tag_from_sensorscalled with the right diffed key sets; assert reconciliation exceptions are swallowed).tests/integration/test_dashboard_repo.pyor a newtests/integration/test_dashboard_manifest_reconciliation.py— AC-1, AC-2, AC-4, AC-6 end-to-end against real Postgres.tests/api/test_dashboards.py— AC-3 (422 on mismatchedlayout/sensors).- A
ManifestHub-integration test (real hub, fake session factory) — AC-7.
Things to avoid
- Parsing
layoutserver-side to derivesensors— see Design Decision. - Diffing against the dashboard’s previous
sensorsvalue to decide what to add/remove — FR-5 requires diffing againstget_sensors_with_tag’s live result, for self-healing idempotency. - A read-then-full-list-write for the
dashboard-*tag itself (FR-8) — only FR-6’s caller-facing merge (a different, narrower, already-accepted race per NFR-4) may still do that. - Letting a reconciliation failure raise out of
create_dashboard/update_dashboard/delete_dashboard— FR-10 requires it stay best-effort, likeSensorService._fan_out_upsert. - Reusing
app.trigger_expr.types.SensorRefJSONas the Pydantic field type forDashboardSensorRef— it’s aTypedDictowned by an unrelated subsystem, not this resource’s schema file. - Adding the
sensorscolumn with onlynullable=False+ a Python-side ORMdefault=listand noserver_default—app.dashboardsalready has rows by this point; the migration fails without a real DB-side default (§8). - Running delete-path reconciliation after
DashboardRepository.delete(mirroring create/update’s ordering) or swallowing its tag-write failure — both leave orphaneddashboard-{id}tags with no dashboard left to re-save and self-heal them (FR-7, FR-10a, NFR-3a). - Treating a failed prior-tags read in
upsert_sensorasold_tags = []for purposes of FR-6’s preserve step — that silently wipes everydashboard-*tag on the sensor instead of the intended no-op-on-tagsdegradation (FR-6a).
10. Dependencies
- Consumer: control-api-ui, which must start (a) computing and sending
sensorsalongsidelayouton every create/update, and (b) actually subscribing toGET /api/manifests/dashboard-{id}/eventsto get any benefit from this — both tracked separately in that repo. Until that lands, this spec is inert (dashboards get tagged; nothing consumes it). - No new libraries. Postgres JSONB
||/-operators are used via raw casts, same asget_sensors_with_tagalready does — no new extension/dependency.
11. Definition of Done
-
sensorsfield added toDashboard/schemas, with thelayout↔sensorsboth-or-neitherPATCHconstraint (FR-1–FR-2, AC-3). - Tag reconciliation on create/update/delete, diffed against live
get_sensors_with_tagstate (FR-3–FR-5, AC-1, AC-2, AC-4). - Atomic
add_tag_to_sensors/remove_tag_from_sensorsrepository methods, no read-modify-write for the dashboard-tag path (FR-8). -
dashboard-reserved-prefix preserve/strip inSensorService.upsert_sensor(FR-6, AC-5). - Reconciliation fans out to
ManifestHub; best-effort for create/update but failure-propagating (tag-write portion only) for delete (FR-9, FR-10, FR-10a, AC-7, AC-8). - Prior-tags read failure in
upsert_sensordegrades to skipping thetagsfield, never to wipingdashboard-*tags (FR-6a, AC-8a). - Not-yet-reporting sensors documented as a self-healing limitation, not an error
(NFR-3, AC-6);
sensors-alonePATCHis the documented recovery path. - Migration adds
sensorscolumn with a realserver_default(not just an ORM default), safe againstapp.dashboards’ existing rows (AC-8b). - Unit + integration + api tests cover AC-1–AC-8.
-
uv run pytest tests/green;black .clean.