docs(wiki): capture cloud-service SaaS requirements (postponed)
Multi-tenant SaaS layered on the offline model: link-up monitoring of the signed ledger, device status, financials; one admin → many sites; per-site secret custody; recurring fee. Records the four tensions, the confirmed secrets boundary (sync creds + device-password escrow + app identity, NOT the signing key), and the two in-discussion corrections that stand (NetBird already solves booth isolation; remote barrier-open is pulseOpen-and-signed, driven by the unmanned future). status: open, postponed. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -0,0 +1,181 @@
|
|||||||
|
---
|
||||||
|
type: decision
|
||||||
|
tags: [parking, cloud, saas, multi-tenant, monitoring, netbird, threat-model, offline-first]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-07-13
|
||||||
|
status: open
|
||||||
|
---
|
||||||
|
|
||||||
|
# Cloud service — multi-tenant SaaS for fleet monitoring & control
|
||||||
|
|
||||||
|
> **Status: postponed (2026-07-13).** Captured as context, not a commitment. This records an
|
||||||
|
> early requirements/architecture discussion so it isn't re-derived from scratch later. No app
|
||||||
|
> code, no schema. Two of the initial requirements were **corrected in-discussion** (see
|
||||||
|
> "Corrections" below) — read those before treating any first-pass answer as settled.
|
||||||
|
|
||||||
|
## The idea
|
||||||
|
|
||||||
|
The offline backup model we ship today is the right **tradeoff for offline sites** and stays.
|
||||||
|
On top of it, the user wants an **online, multi-tenant SaaS** — the "**cloud service**" — that
|
||||||
|
subscribing park sites connect to for **real-time (link-up) monitoring**: the signed ledger,
|
||||||
|
device status, financial reports, and whatever else a site reports. One **admin owns more than
|
||||||
|
one site** (a portfolio). The cloud also **custodies per-site secrets**. Business model: recurring
|
||||||
|
per-site monthly/yearly fee — a revenue line the offline appliance alone can't produce.
|
||||||
|
|
||||||
|
This is the customer-facing evolution of the off-site control plane that [[fleet-deployment-komodo]]
|
||||||
|
already stood up (**Komodo Core**, **NetBird** mesh, **Gitea** registry). Much of the transport and
|
||||||
|
Tier-0 reasoning there carries over directly; this page is about turning that internal ops plane into
|
||||||
|
a **multi-tenant product**.
|
||||||
|
|
||||||
|
## The four hard tensions (what makes a naïve SaaS wrong here)
|
||||||
|
|
||||||
|
The booth's two governing forces ([[offline-first]], [[threat-model]]) plus the signed ledger
|
||||||
|
([[append-only-event-chain]]) make the "obvious" SaaS shape wrong. Four tensions dominate:
|
||||||
|
|
||||||
|
1. **Offline-first vs. real-time monitoring.** The cloud must **never be in the critical path** of
|
||||||
|
entry/exit/payment/barrier ([[offline-first]]). It is a **read-mostly mirror + control-plane**, fed
|
||||||
|
by the booth when the link is up, tolerant of hours/days offline, and unable to block booth
|
||||||
|
operation by being down. "Real-time" = *near*-real-time when up, **gracefully stale** when not —
|
||||||
|
and the UI must show staleness **honestly** (last-seen everywhere), never paint a dark site green.
|
||||||
|
|
||||||
|
2. **The signed ledger must stay *verifiable* in the cloud, not merely displayed.** If subscribers
|
||||||
|
see "their ledger" in the cloud, the cloud copy must be **re-verified server-side** — re-check the
|
||||||
|
hash chain + signatures on ingest, flag gaps/breaks/forks loudly. The [[threat-model|operator-as-
|
||||||
|
adversary]] extends upward: an operator may want the cloud *not* to see certain events, so the sync
|
||||||
|
must be **gap-evident** (sequence continuity). This is both the anti-tamper mechanism **and** a
|
||||||
|
headline feature — *"we can prove your revenue record wasn't altered, even by your own night
|
||||||
|
shift."* See [[reconciliation]] (this is reconciliation, productised).
|
||||||
|
|
||||||
|
3. **Secrets for every site — the scariest requirement.** A central secret store for hundreds of
|
||||||
|
sites is a single juicy target. The custody boundary must be deliberate — see "Secrets boundary".
|
||||||
|
|
||||||
|
4. **Multi-tenancy under operator-as-adversary — now at two levels.** One admin, many sites ⇒ a new
|
||||||
|
**portfolio-owner** role *above* the existing per-site roles ([[local-jwt-auth]] admin/operator/
|
||||||
|
cashier/readonly). Row-level tenant isolation must be **airtight** — a bug now leaks *another
|
||||||
|
company's* revenue, not just an intra-site escalation. Every row carries `tenant_id` + `site_id`,
|
||||||
|
non-optional in the query path (not a filter someone can forget). **Cloud identity is separate from
|
||||||
|
booth-local auth** — the booth keeps its offline JWT/bcrypt login untouched; a site never
|
||||||
|
authenticates its *users* against the cloud (that would break [[offline-first]]).
|
||||||
|
|
||||||
|
## Secrets boundary (settled-in-principle 2026-07-13)
|
||||||
|
|
||||||
|
User confirmed the cloud custodies **three** classes — and **not** the crown jewel:
|
||||||
|
|
||||||
|
| Class | Cloud custodies? | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Sync/connection creds + ledger **public** (verify) key | ✅ yes | Per-site uplink credential + the public half to *verify* signatures. Smallest blast radius. |
|
||||||
|
| **Device/controller passwords** (Dingtian `relay_pw`, camera creds, push tokens) | ✅ yes, as **escrow** | Solves the real pain: lost `relay_pw` after a DB reset ([[dingtian-relay]]). See escrow rules below. |
|
||||||
|
| App/admin identity (portfolio login) | ✅ yes | Cloud-side identity for portfolio admins. Separate from booth-local auth. |
|
||||||
|
| Ledger **signing** key / [[atecc608\|ATECC608]] private key, LUKS/TPM material | ❌ **never** | Centralising the signer **kills the anti-fraud model** ([[append-only-event-chain]], [[hardware-signer-options]]). The user did **not** pick this. |
|
||||||
|
|
||||||
|
**How device-password escrow must work (so it earns its keep instead of becoming the breach):**
|
||||||
|
|
||||||
|
- **Envelope encryption, per-tenant DEK**, DEKs wrapped by a KMS master key; a DB dump is ciphertext,
|
||||||
|
every decrypt is KMS-audited.
|
||||||
|
- The cloud is an **escrow, not an operational credential store**. Its job is "**give the booth back
|
||||||
|
its `relay_pw`** after a wipe," *not* "the cloud logs into the Dingtian." Decryption happens **at the
|
||||||
|
booth** (booth fetches its own wrapped blob, unwraps locally); ideally the cloud never holds
|
||||||
|
plaintext device secrets in memory. This keeps the [[dingtian-http-api-unauthenticated|unauthenticated-
|
||||||
|
CGI]] exposure host-local.
|
||||||
|
- **The booth threat model applies upward:** writes to escrow are append/version ops the operator
|
||||||
|
can't silently rewrite; reads are logged where the operator can't scrub them.
|
||||||
|
- Sellable as: *"your device credentials survive any wipe, encrypted so even we can't read them in
|
||||||
|
bulk."*
|
||||||
|
|
||||||
|
## Corrections made in-discussion (2026-07-13) — read these
|
||||||
|
|
||||||
|
The first pass argued *against* the user's two boldest choices ("cloud reaches into the booth";
|
||||||
|
implicitly, "no remote barrier open"). **The user corrected both, and the corrections stand.**
|
||||||
|
|
||||||
|
### Correction 1 — NetBird already solves the isolation objection
|
||||||
|
|
||||||
|
Initial worry: a cloud tunnel *into* the booth is a new inbound attack surface on every site. **But
|
||||||
|
park-buzi is already monitored remotely over a NetBird private mesh** (WireGuard) — the same
|
||||||
|
mesh [[fleet-deployment-komodo]] uses. The booth **dials out** to join the overlay; **nothing is
|
||||||
|
exposed** on the booth PC. So "cloud reaches booth" is the booth-dialed reverse-channel pattern
|
||||||
|
**already in production**, not a new hole. The objection is **withdrawn.** What it *shifts* rather than
|
||||||
|
removes:
|
||||||
|
|
||||||
|
- Trust moves to the **overlay's identity/ACL layer**: "cloud can reach the booth" now means "any
|
||||||
|
peer the mesh authorizes can reach the booth host." **Mesh ACLs must enforce the same tenant
|
||||||
|
isolation as the app layer** — site A's admin never gets a route to site B's booth. Multi-tenant
|
||||||
|
isolation in a different hat.
|
||||||
|
- **The access-controller VLAN still holds:** the mesh terminates at the **host**, not the controller
|
||||||
|
segment. A cloud peer talks to the booth API; the **booth** talks to the Dingtian/UHPPOTE
|
||||||
|
([[network-isolation]], [[access-direction-is-per-relay]]). The cloud never gets an L3 route to the
|
||||||
|
UDP relay.
|
||||||
|
- **NetBird's control plane joins the trust base** (self-hosted = another service to harden; their
|
||||||
|
SaaS = a third party who can authorize peers). A conscious call, not an architecture change.
|
||||||
|
|
||||||
|
### Correction 2 — remote barrier-open is *compatible* with barrier-not-a-door, and the unmanned future *requires* it
|
||||||
|
|
||||||
|
Initial worry: the cloud must never open a barrier. **The user's driver is the [[autonomous-direction|
|
||||||
|
unmanned-site]] future** — no operator on-site; if the exit reader or payment dies, *someone* must open
|
||||||
|
the barrier remotely rather than trap people ("we can't take hostages because a stupid device is not
|
||||||
|
responsive"). This is **right**, and it does **not** violate [[barrier-not-a-door]]:
|
||||||
|
|
||||||
|
- That rule was **never** "no remote open." It forbids driving the barrier as a **timed auto-close**
|
||||||
|
("open for N ms"); physical safety (loop-detector, anti-crush reversal) lives in the **barrier
|
||||||
|
firmware**. A remote human pressing "open" is an **intent expression** — exactly `pulseOpen`. It's
|
||||||
|
the [[fail-state-safety|exit-fails-open]] value, triggered by a remote human instead of a power-loss.
|
||||||
|
- Constrain the **how**, not the whether (this is the command where [[threat-model|operator-as-
|
||||||
|
adversary]] bites hardest — a remote "let this car out free" is the classic fraud):
|
||||||
|
- **Every remote open is a first-class signed ledger event** ([[append-only-event-chain]]): appended,
|
||||||
|
hash-chained, signed, with **actor** (which cloud identity), **reason code**, and **site/relay**.
|
||||||
|
Control power and audit come as a **pair** — the same discipline [[setup-relay-test]] and
|
||||||
|
[[booth-exit-flow|audited re-open]] already apply locally.
|
||||||
|
- A **distinct, high-privilege capability**, not bundled into "monitoring" — a readonly portfolio
|
||||||
|
viewer can't open barriers.
|
||||||
|
- **The booth stays the enforcer:** cloud sends *intent*; the booth validates (for-me? authorized
|
||||||
|
peer? signed?) and issues `pulseOpen` to its own relay. Cloud never touches the relay.
|
||||||
|
- **Cloud can't be the *sole* egress path.** A fully unattended site needs a **local fail-open on
|
||||||
|
host-loss** + physical override too — offline-first means the cloud is a *convenience* remote-open
|
||||||
|
path, not the *only* one, or you've recreated "device down = hostages" one layer up.
|
||||||
|
|
||||||
|
> **Emergent tenet:** an unattended site is a **higher** safety bar than an attended one, not a lower
|
||||||
|
> one. Every local failure mode (barrier stuck, payment dead, network down) needs an answer that
|
||||||
|
> **doesn't require the cloud**; the cloud makes resolution *nicer*, not *possible*. Fold into
|
||||||
|
> [[autonomous-direction]] and [[fail-state-safety]] when this is picked up.
|
||||||
|
|
||||||
|
## What looks straightforward (agreed quickly)
|
||||||
|
|
||||||
|
- **Transport:** the existing **NetBird overlay** (booth-dialed, nothing exposed) — not a bespoke
|
||||||
|
channel. Reuses [[fleet-deployment-komodo]].
|
||||||
|
- **Sync:** **booth-push, verify-on-ingest** — booth streams ledger + device telemetry
|
||||||
|
([[device-events]]) + snapshot metadata + financial data outbound; cloud **re-verifies the chain +
|
||||||
|
signatures** and flags gaps.
|
||||||
|
- **Staleness first-class in the UI:** every site tile shows last-seen; a dark site is visibly stale.
|
||||||
|
- **DB:** almost certainly **PostgreSQL** — already the named deferred sync target ([[drizzle-orm]],
|
||||||
|
[[technology-stack]]); the Drizzle schemas are meant to port to it.
|
||||||
|
|
||||||
|
## The genuinely open questions (postponed — pick up here)
|
||||||
|
|
||||||
|
1. **What does "real-time" mean to the buyer?** Live-ish (seconds, streaming uplink → **heavier
|
||||||
|
booth**) vs. every-few-minutes rollups (cheap, still sells "monitoring"). This gap is **most of the
|
||||||
|
engineering cost** and drives how heavy the booth-side uplink must be.
|
||||||
|
2. **Financial reports computed where?** Cloud **re-derives** revenue from the verified ledger →
|
||||||
|
independently trustworthy (*"we don't take the booth's word for it"*) but the cloud must implement
|
||||||
|
the [[tariff]] pricing logic. Vs. booth sends **pre-computed rollups** (cheaper, but trusts the
|
||||||
|
booth's math). Lean: **cloud re-derives** — the whole point of [[threat-model|operator-adversary]]
|
||||||
|
is not to trust the site's self-report ([[reporting-analytics]] is already "projections over the
|
||||||
|
signed log").
|
||||||
|
3. **Hosting + licensing.** The booth stack is deliberately all-MIT/Apache/BSD ([[technology-stack]]);
|
||||||
|
a SaaS the user **hosts** has more freedom (like the [[fleet-deployment-komodo|Komodo GPL]] /
|
||||||
|
[[vision-service|AGPL]] self-host exceptions) — but anything that ever ships **on-premise** re-binds
|
||||||
|
the constraint.
|
||||||
|
4. **Custodianship is leverage *and* liability.** Holding other companies' financial records + device
|
||||||
|
secrets is what makes the subscription **sticky** — and what pulls in **backups, retention policy,
|
||||||
|
breach disclosure, data-residency**. A deliberate "yes, we want to be the custodian" call, with the
|
||||||
|
obligations that implies. (Cloud/Core is a **Tier-0 asset** for the whole fleet — the same bar
|
||||||
|
[[fleet-deployment-komodo]] already sets for Core.)
|
||||||
|
|
||||||
|
## Relates
|
||||||
|
|
||||||
|
- [[fleet-deployment-komodo]] — the off-site control plane (Komodo Core + NetBird) this productises;
|
||||||
|
Core-as-Tier-0 reasoning carries over.
|
||||||
|
- [[autonomous-direction]] — the unmanned future that *drives* remote barrier-open (Correction 2).
|
||||||
|
- [[reconciliation]] — the cloud *is* reconciliation, productised (verify-on-ingest, gap-evidence).
|
||||||
|
- [[append-only-event-chain]] / [[hardware-signer-options]] — why the **signing** key stays on the
|
||||||
|
booth even as everything else centralises.
|
||||||
|
- [[threat-model]] / [[offline-first]] — the two forces every tension above traces back to.
|
||||||
|
- [[network-isolation]] / [[access-direction-is-per-relay]] — why the mesh terminates at the host.
|
||||||
+2
-1
@@ -7,7 +7,7 @@ updated: 2026-07-02
|
|||||||
# Index
|
# Index
|
||||||
|
|
||||||
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
|
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
|
||||||
Counts: 4 sources · 19 entities · 47 concepts · 7 decision records.
|
Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
|
||||||
|
|
||||||
## Overview & navigation
|
## Overview & navigation
|
||||||
- [[overview]] — the top-level synthesis and entry point.
|
- [[overview]] — the top-level synthesis and entry point.
|
||||||
@@ -127,6 +127,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 7 decision records.
|
|||||||
- [[open-questions]] — 9 open items (procurement + JWT key + FX + pay-station money corners); ESP32 device auth deferred.
|
- [[open-questions]] — 9 open items (procurement + JWT key + FX + pay-station money corners); ESP32 device auth deferred.
|
||||||
- [[access-controller-button-flow]] — ✅ RESOLVED: Dingtian decoupled inputs enable ticket-first entry (was a UHPPOTE/ZKTeco blocker).
|
- [[access-controller-button-flow]] — ✅ RESOLVED: Dingtian decoupled inputs enable ticket-first entry (was a UHPPOTE/ZKTeco blocker).
|
||||||
- [[autonomous-direction]] — roadmap: toward fully unmanned (no booth); reshapes threat model + fail-state.
|
- [[autonomous-direction]] — roadmap: toward fully unmanned (no booth); reshapes threat model + fail-state.
|
||||||
|
- [[cloud-service-saas]] — 📌 POSTPONED: multi-tenant SaaS for fleet monitoring/control; productises the NetBird/Komodo control plane. Four tensions (offline-first vs real-time, verifiable-ledger-in-cloud, secrets custody, two-level tenancy); signing key stays on the booth; NetBird already solves isolation; remote barrier-open is `pulseOpen`+signed (the unmanned driver).
|
||||||
- [[dingtian-vs-mqtt]] — transport choice: direct HTTP/UDP now, MQTT parked until multi-lane scale.
|
- [[dingtian-vs-mqtt]] — transport choice: direct HTTP/UDP now, MQTT parked until multi-lane scale.
|
||||||
- [[session-model]] — business layer start: session = projection; transient-first; pay-on-foot. New event types.
|
- [[session-model]] — business layer start: session = projection; transient-first; pay-on-foot. New event types.
|
||||||
- [[vision-service]] — build a host-side ANPR + vehicle-verification service; replaces edge-LPR; scoped AGPL exception.
|
- [[vision-service]] — build a host-side ANPR + vehicle-verification service; replaces edge-LPR; scoped AGPL exception.
|
||||||
|
|||||||
+20
@@ -2540,3 +2540,23 @@ to no reset-db category, silently surviving even `--all`. Added `--diagnostics`
|
|||||||
tariff_drafts under `--config`, and a drift guard that refuses to run when any table is
|
tariff_drafts under `--config`, and a drift guard that refuses to run when any table is
|
||||||
uncategorized ([[local-dev-workflow]], [[appliance-provisioning]] §7d). 8 new tests
|
uncategorized ([[local-dev-workflow]], [[appliance-provisioning]] §7d). 8 new tests
|
||||||
(3 button-light backoff, 5 coalescing); guard + both new wipes verified on a scratch DB.
|
(3 button-light backoff, 5 coalescing); guard + both new wipes verified on a scratch DB.
|
||||||
|
|
||||||
|
## [2026-07-13] decision | Cloud service — multi-tenant SaaS (postponed, context captured)
|
||||||
|
From a design conversation, not a source. The user floated an online, multi-tenant SaaS (the
|
||||||
|
"cloud service") on TOP of the offline backup model (which stays, as the offline-site tradeoff):
|
||||||
|
subscribing park sites get real-time (link-up) monitoring of the signed ledger, device status,
|
||||||
|
and financial reports; one admin owns many sites; the cloud custodies per-site secrets; recurring
|
||||||
|
per-site fee = a revenue line. Recorded as [[cloud-service-saas]] (status: open, POSTPONED per the
|
||||||
|
user) so it isn't re-derived later. It productises the off-site control plane already stood up in
|
||||||
|
[[fleet-deployment-komodo]] (Komodo Core + NetBird). Captured: the four hard tensions (offline-first
|
||||||
|
vs real-time; the ledger must be VERIFIABLE not just displayed in the cloud; central secret custody;
|
||||||
|
two-level tenancy under operator-as-adversary), the secrets boundary the user confirmed (sync creds
|
||||||
|
+ device-password ESCROW + app identity — but NOT the signing/ATECC608 key, which stays on the
|
||||||
|
booth), and TWO in-discussion corrections that stand: (1) NetBird already solves the "cloud reaches
|
||||||
|
booth" isolation objection — park-buzi is monitored that way today, booth-dialed, nothing exposed;
|
||||||
|
(2) remote barrier-open is COMPATIBLE with [[barrier-not-a-door]] (it's `pulseOpen`/intent, never
|
||||||
|
timed-close) and is DRIVEN by the [[autonomous-direction]] unmanned future — gated as a distinct
|
||||||
|
privilege + a signed ledger event with actor+reason, with the booth as enforcer and a local
|
||||||
|
fail-open that can't depend on the cloud. Four open questions parked (real-time definition, where
|
||||||
|
reports are computed, hosting/licensing, custodianship-as-liability). Cross-linked; index count
|
||||||
|
7→8 decisions.
|
||||||
|
|||||||
Reference in New Issue
Block a user