docs(wiki): DT-008 phantom-scan diagnosis + backfill bypass/relay-test concept pages
Two independent wiki updates bundled (all docs):
1. dingtian-dt008-reader.md: phantom optical decodes on the park-buzi EXIT
reader (empty pre-opening site, low-sun afternoons). Chain of evidence:
READ log lines carry the reader's own serial (H05MA5B0) → physical device,
not a network source; snapshot shows nobody present; code shapes are the
giveaway (6-digit numerics = checksum-less Interleaved 2-of-5, lone "C" =
Code39/Codabar artifact) → 1D engine decoding sun-made stripe patterns
(striped arm, fence shadows, glare). No fraud exposure (11-digit Luhn ids
can't match); noise only. Fix on the entity page: vendor-tool symbology cut
to QR+Code128 + min decode length, BOTH readers; config lives ON the device
→ re-apply after any factory reset/swap. Deliberately NOT filtering
impossible codes server-side — probe recording is the anomaly path's job.
2. Backfilled two shipped-but-undocumented features (six code files already
linked the first page as if it existed):
- concepts/entry-presence-bypass.md — admin drops a FAULTY presence signal
(granular radar/camera by decision, not a master switch); every flip is a
signed config_change; persists till off; tickets stamped presenceBypassed;
radar-bypass cooldown tradeoff; "the admin is not the adversary, but
trusted never means invisible".
- concepts/setup-relay-test.md — admin-only commissioning pulse, signed
barrier_open_command BEFORE the fire so a test open never reads as the
out-of-band-open fraud signal; saved controllers/declared relays only;
radarAlert lamps excluded; pulseOpen only.
Cross-linked from operator-issued-entry.md, cataloged in index.md, logged.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, entry, admin, devices, threat-model, presence]
|
||||
sources: []
|
||||
updated: 2026-07-04
|
||||
status: settled
|
||||
---
|
||||
|
||||
# Entry presence-gate bypass (faulty radar / faulty camera)
|
||||
|
||||
The transient entry gates require a **real vehicle** before a ticket can mint: the radar/loop
|
||||
presence input and the camera vehicle-detection ([[entry-double-press]] for the physical button,
|
||||
[[operator-issued-entry]] for the booth button). When one of those devices is **faulty** — a dead
|
||||
radar, a camera that stopped pushing ([[g3h-anpr-push-gotchas|it happens]]) — the gate blocks
|
||||
**legitimate** transient entry until support fixes the hardware. This feature lets the **admin**
|
||||
drop a specific signal as a requirement until then. Built 2026-07-02 (migration `0020`).
|
||||
|
||||
## The principle: the admin is NOT the adversary
|
||||
|
||||
The [[threat-model]] adversary is the **booth operator**; the admin is the trusted party. So the
|
||||
admin *may* weaken an anti-fraud gate — but weakening it stays **attributed and auditable**
|
||||
(signed), because "trusted" never means "invisible" in this system. Compare
|
||||
[[operator-issued-entry]], where the untrusted role gets capability + a red flag on every use;
|
||||
here the trusted role gets a switch + a signed record of flipping it.
|
||||
|
||||
## Decisions (2026-07-02)
|
||||
|
||||
- **Granular, not a master switch.** One flag per signal: a faulty camera drops only the camera
|
||||
check (radar still gates); a faulty radar drops only radar. Chosen over a single "bypass all"
|
||||
toggle so a single broken device never silently disables the *other*, still-working gate.
|
||||
- **Persists until turned OFF, and every flip is signed.** No auto-expiry (support visits are
|
||||
unpredictable); instead each enable/disable appends a **`config_change`** ledger event
|
||||
(`setting: entryPresenceBypass.<signal>`, `value`, `prev`, `operator`) — the append-only chain
|
||||
records *who weakened the gate and when*, and the OFF transition too. A no-op write (same value)
|
||||
signs nothing.
|
||||
|
||||
## What a bypass changes
|
||||
|
||||
- **Physical button** (`EntryFlow.#suppressReason`): camera bypassed → the camera press-gate is
|
||||
skipped; radar bypassed → the presence-loop check is skipped and the press falls through to the
|
||||
**cooldown backstop**. ⚠️ A dead loop can't re-arm one-car-one-ticket, so with radar bypassed
|
||||
and **no `entryCooldownSec` configured there is no anti-double-press left** — the admin's
|
||||
accepted tradeoff while bypassed (set a cooldown when bypassing radar).
|
||||
- **Operator button** (`issueForOperator`): the bypassed signal is dropped as a requirement; a
|
||||
refusal only happens when a **still-required** signal fails to confirm.
|
||||
- **Auditability on every ticket:** a ticket minted under a bypass carries
|
||||
`presenceBypassed: ["radar"|"camera", ...]` on its signed `vehicle_entry` (and refusal anomalies
|
||||
record it too) — so reconciliation can always tell which entries happened under a weakened gate.
|
||||
- **Booth lights** ([[booth-console]]): a bypassed signal renders as satisfied, so the operator's
|
||||
button-enable logic matches the server's.
|
||||
|
||||
## As-built
|
||||
|
||||
- **Schema:** `site_config.bypass_presence_radar` / `bypass_presence_camera` (booleans, default
|
||||
off = the normal both-required gate), migration `0020_entry_presence_bypass.sql`. Read **live**
|
||||
per decision — a toggle needs no restart.
|
||||
- **Endpoint:** `PUT /api/site-config/presence-bypass` (`routes/site.ts`), admin-only
|
||||
(`site:update`), signs one `config_change` per actually-changed signal, 400 on non-boolean/empty.
|
||||
- **UI:** SetupWizard "presence gate" panel (two checkboxes + an amber "active" warning) after the
|
||||
controller section; `config_change` events render amber in the booth feed.
|
||||
- **Tests:** `entry-presence-bypass.test.ts` (gate decisions under each combination),
|
||||
`presence-bypass-route.test.ts` (RBAC, signing, no-op, validation), and
|
||||
`entry-press-gate.test.ts` (camera bypass on the physical press).
|
||||
- Fixing this surfaced a latent bug: `firstRelayByDirection` never attached `presenceInput`, so
|
||||
the operator-issue radar check had always read "presence loop unavailable" (fixed 2026-07-02).
|
||||
@@ -2,7 +2,7 @@
|
||||
type: concept
|
||||
tags: [parking, booth, entry, threat-model, anpr, presence]
|
||||
sources: []
|
||||
updated: 2026-07-01
|
||||
updated: 2026-07-04
|
||||
status: settled
|
||||
---
|
||||
|
||||
@@ -33,6 +33,11 @@ with phantom tickets, and (crucially) it guarantees the entry snapshot captures
|
||||
what [[plate-reconciliation]] reads at exit. **No presence loop configured → the feature is
|
||||
unavailable** at that site (we require both; no weaker camera-only fallback).
|
||||
|
||||
> **Bypass (2026-07-02):** when one of the two devices is FAULTY, the admin can drop that signal
|
||||
> as a requirement via [[entry-presence-bypass]] (granular, signed `config_change`, persists till
|
||||
> turned off). The gate then requires only the still-working signal; tickets minted under a bypass
|
||||
> carry `presenceBypassed` on the signed entry, and refusal anomalies record it too.
|
||||
|
||||
**Enforced on BOTH sides.** The UI only enables the entry [[booth-console|BarrierLight]] as a clickable
|
||||
issue-control when `radar.entry && lanes.entry` (both true) and the operator holds `session:create`.
|
||||
The **server re-checks** current presence (`LaneStatus.snapshot().entry === true` AND the entry relay's
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, setup, devices, integrity, admin]
|
||||
sources: []
|
||||
updated: 2026-07-04
|
||||
status: settled
|
||||
---
|
||||
|
||||
# Setup relay test (signed, admin-only barrier pulse)
|
||||
|
||||
Commissioning a lane needs a way to prove **wiring**: does relay N on this controller actually
|
||||
lift *this* barrier? Before this feature (built 2026-07-01) the only way was to fake a credential
|
||||
or short an input — both of which pollute the flows they exercise. Now the Setup controller
|
||||
section offers a per-relay **Test** button that pulses the relay directly.
|
||||
|
||||
## The design constraint: a test open must be SIGNED
|
||||
|
||||
The core anti-fraud rule ([[append-only-event-chain]]) is that **a physical barrier open with no
|
||||
matching signed command is THE fraud signal**. An unsigned test pulse would therefore read as
|
||||
fraud in any reconciliation of controller logs vs. ledger. So the test **signs a
|
||||
`barrier_open_command` BEFORE the pulse fires** — same ordering invariant as every real open:
|
||||
|
||||
- `source: "manual"` (a deliberate human action, same as an operator barrier open),
|
||||
`identity: relay-test:<controllerId>:<relay>`,
|
||||
- payload: `reasonPayload("setup.relayTest", { operator, relay, controller })` + `relayTest: true`
|
||||
so reconciliation and the feed can tell a test from an intervention.
|
||||
- No `EventLog` available (boot ordering) → the endpoint refuses (503) rather than fire unsigned.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Admin-only** (`site:update`) + CSRF — the operator (the [[threat-model]] adversary) cannot
|
||||
pulse barriers from Setup.
|
||||
- **Saved controllers only**, and only relays the saved config **declares** (unknown relay → 400,
|
||||
unknown controller → 404, nothing signed on any refusal). No free-form "pulse anything" seam.
|
||||
- **`radarAlert` (lamp) relays are excluded** in the UI — they are aux outputs, not barriers
|
||||
([[button-light-indicator]]); the test drives `pulseOpen` only, so [[barrier-not-a-door]] holds
|
||||
(intent-only; the barrier firmware owns the close).
|
||||
- UI confirms before firing (`Test` → confirm dialog → pulse), per-relay buttons in the
|
||||
controller assignment row.
|
||||
|
||||
## As-built
|
||||
|
||||
- Endpoint `POST /api/setup/test-relay` (`routes/setup.ts`) — validates, signs, then
|
||||
`registry.create(...)` → `pulseOpen(relay)`. Feature-detects `pulseOpen` on the built device.
|
||||
- UI: `RelayTester` in `SetupWizard.tsx` (access category only). Reason code `setup.relayTest`
|
||||
in `@parking/shared` + both web catalogs ([[i18n]]).
|
||||
- Tests: `setup-relay-test.test.ts` — RBAC 403, CSRF 403, signed command on success, 400/404
|
||||
refusals sign nothing, bad relay value.
|
||||
@@ -2,7 +2,7 @@
|
||||
type: entity
|
||||
tags: [parking, hardware, readers, qr, dingtian]
|
||||
sources: [dingtian-dt008]
|
||||
updated: 2026-06-28
|
||||
updated: 2026-07-04
|
||||
status: open
|
||||
---
|
||||
|
||||
@@ -120,6 +120,33 @@ host in the **vendor tool**; assign + enter its serial + bind it here.
|
||||
- `output` is replied as `0` (Access). Confirm on hardware whether the reader needs `1`/`2` (WG26/34)
|
||||
to drive its access line, vs. `0`.
|
||||
|
||||
## ⚠️ Phantom optical decodes from sunlight patterns (park-buzi, 2026-07-04)
|
||||
|
||||
With the site EMPTY (pre-opening, verified live + by snapshot), the **exit reader
|
||||
(`cjihao=H05MA5B0`) pushed spontaneous scans** at random afternoon times (observed 16:38–18:14,
|
||||
low-western-sun hours): `cardid` values like `997492`, `389861`, `024358`, `192793` — and once a
|
||||
lone **`C`**. Server logs (`READ serial=` lines) confirm the pushes carry the reader's own serial
|
||||
and resolve to its assigned row, so this is **the physical reader decoding, not a network source**.
|
||||
|
||||
**Diagnosis:** the scan engine ships with many 1D symbologies enabled, some with weak/no checksums.
|
||||
Six-digit all-numeric strings are the signature of **Interleaved 2-of-5** (even-length, digits-only,
|
||||
no checksum — any high-contrast stripe pattern of the right proportions "decodes"); a lone `C` is a
|
||||
**Code39/Codabar** artifact (Codabar start/stop chars are A–D). Low sun creates exactly such
|
||||
patterns at a gate: the striped barrier arm, fence/railing shadows sweeping as the sun moves, glare
|
||||
bands. RFID noise would instead give repeating UID-shaped values.
|
||||
|
||||
**Impact: noise, not risk.** Every phantom was REFUSED fail-closed (`exit.refused.noSession`, a
|
||||
signed anomaly — #52–58 in the feed); a phantom can never match a ticket ([[ticket-encoding]] ids
|
||||
are 11-digit + Luhn, so a 6-digit read has nothing to match). Do NOT filter "impossible" codes
|
||||
server-side — recording every probe of an exit reader is what the anomaly path is for; fix at the
|
||||
source instead:
|
||||
|
||||
**Fix (vendor tool, per reader — config lives ON THE DEVICE, not in our DB):** disable every
|
||||
symbology except **QR + Code128** (all our credentials); if offered, set **minimum decode length
|
||||
≥ 10** and require checksums. Apply to BOTH readers. ⚠️ A factory reset or a swapped unit silently
|
||||
re-enables the phantom symbologies — re-apply after any reset/replacement. Physical fallback if any
|
||||
noise survives: hood/visor the window, tilt it down, avoid facing the striped arm.
|
||||
|
||||
## ⚠️ Reply MUST set `Connection: close` (verified on hardware)
|
||||
|
||||
The reader sends `Connection: keep-alive` but **only acts on the verdict (beep/output) once the TCP
|
||||
|
||||
+4
-2
@@ -80,8 +80,10 @@ Counts: 4 sources · 19 entities · 47 concepts · 7 decision records.
|
||||
- [[challenge-response-auth]] — asymmetric nonce scheme for the ESP32 (auth + anti-replay).
|
||||
- [[entry-exit-readers]] — two populations, two integration paths; both can share a relay.
|
||||
- [[entry-exit-points]] — pool-of-spaces model (no lane); per-relay direction, reader→relay binding, camera snapshots.
|
||||
- [[entry-double-press]] — one car = one ticket: per-relay presence gate (loop OR radar) preferred, cooldown fallback; suppressed press = telemetry.
|
||||
- [[button-light-indicator]] — entry button lamp on a spare relay: radar × camera 3-state (blink/solid/off); aux-output; fails OFF.
|
||||
- [[entry-double-press]] — one car = one ticket: per-relay presence gate (loop OR radar) preferred, cooldown fallback (a real backstop since 2026-07-04); camera press-gate + post-hoc duplicate-plate anomaly (2026-07-04).
|
||||
- [[entry-presence-bypass]] — admin drops a FAULTY presence signal (radar/camera, granular) until support fixes it; every flip is a signed config_change; tickets minted under bypass are stamped (2026-07-02).
|
||||
- [[setup-relay-test]] — admin-only per-relay Test button in Setup; signs a barrier_open_command BEFORE the pulse so a test open never reads as fraud (2026-07-01).
|
||||
- [[button-light-indicator]] — entry button lamp on a spare relay: radar × camera 3-state (blink/solid/off); aux-output; fails OFF. The press gate enforces its SOLID state since 2026-07-04.
|
||||
- [[uhppote-vs-esp32]] — comparison: detection vs. prevention.
|
||||
|
||||
## Concepts — business domain
|
||||
|
||||
+24
@@ -2223,3 +2223,27 @@ along the way: camera-vetoed re-arm (defer re-arm until the lane flips free) —
|
||||
silence-timeout "free" never fires inside a queue, so it would suppress every queued car after the
|
||||
first. Proper preventive fix noted open: a pass-through sensor (`passedInput`). 13 new tests
|
||||
(`entry-press-gate.test.ts`, `entry-duplicate-plate.test.ts`); suite 258 green.
|
||||
|
||||
## [2026-07-04] update | Exit reader phantom scans traced to optical 1D decodes (sun patterns)
|
||||
|
||||
Pre-opening park-buzi, empty site: the exit [[dingtian-dt008-reader]] pushed spontaneous 6-digit
|
||||
numeric scans (+ one lone "C") at low-sun afternoon hours; all refused fail-closed as
|
||||
exit.refused.noSession. Server READ logs confirmed the reader's own serial (H05MA5B0) → the
|
||||
physical device decoding, not a network source; a live snapshot confirmed nobody present.
|
||||
Diagnosis: default-enabled weak-checksum 1D symbologies (I2of5 6-digit signature; "C" =
|
||||
Code39/Codabar artifact) decoding sun-made stripe patterns (striped barrier arm, fence shadows,
|
||||
glare). No fraud exposure (11-digit Luhn ids can't match a 6-digit read). Fix recorded on the
|
||||
entity page: vendor-tool symbology cut to QR+Code128 + min length, BOTH readers, re-apply after
|
||||
any factory reset (config lives on the device). Deliberately NOT filtering impossible codes
|
||||
server-side — probe recording is the anomaly path's job.
|
||||
|
||||
## [2026-07-04] update | Backfilled missing concept pages: entry-presence-bypass + setup-relay-test
|
||||
|
||||
Two shipped features (2026-07-01/02) had no wiki pages — worse, six code files and
|
||||
[[entry-double-press]] already linked [[entry-presence-bypass]] as if it existed. Written now:
|
||||
[[entry-presence-bypass]] (admin drops a faulty radar/camera signal, granular by decision, every
|
||||
flip a signed config_change, tickets stamped presenceBypassed, radar-bypass cooldown tradeoff,
|
||||
"the admin is not the adversary" threat-model nuance) and [[setup-relay-test]] (admin-only
|
||||
commissioning pulse, signed barrier_open_command BEFORE the fire so a test open never reads as the
|
||||
out-of-band-open fraud signal, saved-controllers-only, radarAlert lamps excluded). Cross-linked
|
||||
from [[operator-issued-entry]] (bypass note) and cataloged in index.md.
|
||||
|
||||
Reference in New Issue
Block a user