docs(P01): complete edge domain phase — v0.4

---ci---
project: atelier
phase: 1
milestone: v0.4
status: complete
phase_role: execution
phase_tag: v0.3.1
requirements:
  covered: [ATELIER-92, ATELIER-93, ATELIER-94, ATELIER-95, ATELIER-96]
  partial: []
---/ci---
This commit is contained in:
Jon Chery
2026-08-05 15:51:03 +00:00
parent a622419e7d
commit 20992883ff
6 changed files with 1378 additions and 7 deletions
+6 -7
View File
@@ -1,15 +1,14 @@
{
"phase": 0,
"stage": "complete",
"phase": 1,
"stage": "execute",
"milestone": "v0.4",
"phase_role": "pre_execution",
"phase_role": "execution",
"project": "atelier",
"attempts": 0,
"updated_at": "2026-08-05T05:25:00Z",
"updated_at": "2026-08-05T06:00:00Z",
"milestone_complete": false,
"milestone_branch": "milestone/v0.4-edge-quantum-langs",
"phase_branch": "phase/00-pre-execution",
"phase_branch": "phase/01-edge",
"tag_base": "v0.3",
"phase_tag": "v0.3.0",
"release_id": 475
"phase_tag": "v0.3.1"
}
+227
View File
@@ -0,0 +1,227 @@
# CDN — Derived Rules
> Derives from `domains/edge/first-principles.md`. Applies P1
> (Proximity is the Design Driver) and P6 (Cache Invalidation is
> Explicit) primarily, with P5 (idempotent cache fill), P8
> (geographic distribution), and P9 (identity at the edge). For the
> edge-cache-vs-origin decision, see the decision matrix below.
> Cross-links `domains/performance/frontend` for generic caching,
> `domains/security/input-validation` for cache poisoning, and
> `domains/observability/metrics` for cache-hit ratio.
## What a CDN Is (P1 Proximity is the Design Driver)
- A content delivery network is a fleet of PoPs (points of presence)
placed near users. The PoP serves cached content; the origin is
the authoritative source. The CDN's whole purpose is P1: compute
(the cache) is placed near the user so the round trip to the origin
does not bound latency. Latency is a correctness constraint at the
edge (C1), not a performance preference.
- The CDN is the canonical edge-cache architecture (Akamai,
Cloudflare, Fastly): PoPs near users, origin shielding, cache-key
normalization, purge APIs. Atelier derives the
placement/invalidation principles, not the vendor config.
- The boundary with `domains/performance/frontend` is per D-061:
performance owns *generic* caching and optimization (cache what is
expensive, stable, read often — `performance/P5 Caching with
Intent`); edge owns the *geographic, partition-aware* placement and
invalidation angle. A CDN is an edge concern because its defining
trait is geographic distribution (P8) and partition-aware
invalidation (P6), not measurement.
## Cache Key Design (P6 Cache Invalidation is Explicit)
- The cache key is the contract between the URL and the cached
representation. A key that varies on the wrong dimensions serves
the wrong content; a key that varies on too many dimensions
collapses the hit ratio. Key design *is* the invalidation
surface: a key that includes a content hash or version segment
makes invalidation explicit; a key that ignores `Vary` headers
serves stale variants.
- Normalize the key: lower-case the host, strip default ports,
sort query parameters, ignore tracking parameters. A
non-normalized key is a cache-poisoning vector (see
`domains/security/input-validation`) and a hit-ratio destroyer
(see `domains/observability/metrics`).
- A cache with no explicit key strategy is a TTL-less cache under
partition (P6 violation): staleness is silent and unbounded.
```http
# Cache key derivation: vary on what changes content, ignore what
# does not. The key is the tuple (host, normalized-path, sorted-
# query, Vary-headers); the cache entry is the representation + TTL.
Cache-Key: example.com /api/v1/products?sort=price&region=us Vary:Accept-Encoding
Cache-Control: public, max-age=60, s-maxage=600, stale-while-revalidate=300
Vary: Accept-Encoding
```
- `max-age` bounds the browser cache; `s-maxage` bounds the CDN
PoP; `stale-while-revalidate` allows serving stale while
refetching. Each is an explicit invalidation strategy (P6).
## TTL vs Explicit Invalidation (P6, C3 Simplicity)
- **TTL-based invalidation** (`max-age`, `s-maxage`): the cache entry
expires after a duration. Simple, no origin contact required to
invalidate, but bounded staleness is the contract — the entry may
be stale up to TTL. Fits content where eventual consistency is
acceptable (asset fingerprints, lists, derived images).
- **Explicit invalidation** (purge, surrogate keys): the operator
signals the cache to drop entries. Tighter staleness bounds, but
requires the origin or operator to know which entries to purge.
Fits content where staleness is a correctness defect (price
updates, availability, breaking news).
- A TTL-less cache with no explicit invalidation is a P6 violation:
stale-forever under partition. Every cache must have one or the
other (or both), and the choice is documented per content type.
## Cache-Hit / Miss / Origin-Fetch (P5, P6)
- **Hit**: the PoP serves from cache. Latency is PoP-local (P1).
- **Miss**: the PoP has no entry; it fetches from the origin (or an
origin-shield PoP). The fetch must be idempotent (P5) — a retried
miss must not corrupt the cache or double-write side effects.
- **Revalidate**: the PoP holds a stale entry and asks the origin
(`If-None-Match`, `If-Modified-Since`); a 304 refreshes the TTL
without re-fetching the body. Revalidation is the bandwidth-economical
middle ground (C8).
```http
# Conditional revalidation the PoP asks the origin "is this still
# current?" The 304 response refreshes the TTL without a body.
GET /api/v1/products HTTP/1.1
Host: example.com
If-None-Match: "etag-7a3f"
HTTP/1.1 304 Not Modified
ETag: "etag-7a3f"
Cache-Control: s-maxage=600
```
- A cache-hit ratio that is not measured is a gate on noise — see
`domains/observability/metrics` for the SLI/SLO discipline that
makes the hit ratio a meaningful signal. A CDN with no hit-ratio
metric is operating blind (P10 analog).
## Origin Shielding (P1, P8, C8 Economy)
- Origin shielding routes all origin fetches through a single
shield PoP (or shield region). The shield absorbs the
thundering-herd: 10 000 PoPs missing the same URL fetch the origin
once, not 10 000 times. This is C8 Economy (origin bandwidth is
bounded) and P1 (the shield is itself a proximity layer for the
origin).
- Shielding is a geographic decision (P8): the shield sits in a
region close to the origin, not close to the user. The shield is
the inner ring of the CDN; the user-facing PoPs are the outer ring.
- A CDN without origin shielding under a stampede will overload the
origin; a shield that is itself partitioned from the origin must
degrade gracefully (P7) — serve stale per `stale-while-revalidate`
rather than 500.
## Purge Strategies (P6, C3 Simplicity)
| Strategy | Granularity | Latency to Invalidate | Cost | Best for |
|----------|-------------|-----------------------|------|----------|
| URL purge | One URL | Seconds | Low (one entry) | Surgical fixes, single-page corrections |
| Soft purge | One URL (mark stale, serve while refetch) | Seconds | Low | High-traffic URLs where a hard purge causes a stampede |
| Surrogate-key purge | A tag set (e.g., `product:123`, `category:shoes`) | Seconds | Medium (key indexing) | Related-content invalidation (a product update purges all its category pages) |
| Wildcard purge | A path prefix or pattern | Seconds to minutes | High (scan) | Site-wide template changes |
| All-cache purge | Everything | Seconds | Very high (origin stampede) | Disaster recovery only; never the steady-state invalidation path |
- Surrogate-key purge (Fastly, Akamai) is the highest-value
strategy: tag cache entries with content keys, then purge by tag.
This is explicit invalidation at scale (P6) without the origin
stampede of an all-cache purge.
- An all-cache purge as the steady-state invalidation path is a P6
violation dressed as a feature — it pushes the origin load back to
100% miss, defeating the CDN's purpose (P1).
## Cache Poisoning Prevention (P9, cross-link security/input-validation)
- A cache poisoned by a crafted request (a URL with a malicious
header that gets cached and served to others) is a correctness
defect (C1) and a security breach (P9 — the edge node is
exploited). Prevent poisoning by:
- Normalizing the cache key (strip untrusted query parameters,
ignore unknown headers, lower-case the host).
- Validating `Vary` against an allow-list; never `Vary: *` on a
shared cache (poisonable via header injection).
- Treating uncacheable responses (`Set-Cookie`,
`Cache-Control: private`) as never-stored.
- See `domains/security/input-validation` for the general
input-validation discipline the cache key must follow. The cache
key is a validation surface; a non-validated key is an attack
surface.
## Multi-CDN Routing (P8 Geographic Distribution)
- A multi-CDN strategy routes each request to the best PoP across
providers (Akamai + Cloudflare + Fastly). Routing is
location-aware (P8): latency, cost, and availability vary by
region and provider. The DNS layer (or a client-side router)
selects the CDN per request.
- Multi-CDN is a P8 decision, not a vendor-management decision:
geographic distribution is the first-class constraint. A
single-CDN deployment routes everything to one provider's PoPs;
a multi-CDN deployment routes by region, latency, and cost.
- Invalidation across multiple CDNs is harder (P6): each provider
has its own purge API and surrogate-key scheme. A multi-CDN purge
must fan out to all providers; a purge that reaches only one CDN
leaves the others stale. Track purge completion per provider —
see `domains/observability/metrics` for the per-CDN hit-ratio and
purge-latency signals.
```http
# A CDN config example: cache-control headers + a purge rule.
# Origin response: declare the cache contract (P6).
HTTP/1.1 200 OK
Cache-Control: public, max-age=60, s-maxage=600, stale-while-revalidate=300
Surrogate-Key: product:123 category:shoes
ETag: "etag-7a3f"
Vary: Accept-Encoding
# Purge rule (Fastly-style surrogate-key): when product 123
# updates, purge every cache entry tagged product:123 OR
# category:shoes. Explicit, bounded, no origin stampede (P6).
POST /service/svc1/purge
Surrogate-Key: product:123 category:shoes
# Returns: {"status": "ok", "id": "purge-abc"} — poll the purge
# status to confirm completion across all PoPs (P8, P10).
```
## Edge-Cache vs Origin — Decision Matrix (D-069)
| Strategy | When | Latency | Origin Load | Correctness Risk |
|----------|------|---------|-------------|------------------|
| Serve from PoP (cache hit) | The PoP holds a fresh entry (within TTL or revalidated) | Lowest (PoP-local, P1) | None | Low — bounded by TTL staleness (P6) |
| Serve stale while revalidate | The PoP holds a stale entry and `stale-while-revalidate` is set | Low (stale served immediately, refetch in background) | Background refetch (1 per entry) | Medium — stale served up to the revalidate window; acceptable for eventually-consistent content |
| Fetch fresh from origin (miss) | The PoP has no entry, or the content is non-cacheable | High (origin round trip) | Full fetch per miss | Low — fresh by construction; the miss is the correctness floor |
| Origin-shield fetch | Multiple PoPs miss the same URL; the shield collapses the herd | Medium (PoP → shield → origin) | Bounded to one origin fetch per shield (C8) | Low — shield is the inner ring; staleness bounded by shield TTL |
| Purge and serve fresh | Explicit invalidation received (surrogate-key or URL purge) | Medium (purge propagates, then fresh fetch) | Full fetch post-purge | Lowest — explicit invalidation is the tightest staleness bound (P6) |
| Serve from origin directly (bypass cache) | Content is non-cacheable (personalized, real-time) | Highest (every request hits origin) | Full fetch per request | Lowest for correctness, highest for origin load — use sparingly |
- The default is **serve from PoP** when fresh, **fetch fresh from
origin** on miss with **origin-shield** to bound origin load, and
**purge and serve fresh** when explicit invalidation is required.
Bypass-the-cache is for non-cacheable content only — bypassing for
cacheable content is a P1 violation (you have defeated the CDN).
- The correctness risk column is bounded by the invalidation
strategy (P6): every row except "bypass" carries staleness risk
that is bounded by TTL or explicit purge. A row with no
invalidation strategy is a P6 violation.
## What Violates CDN Discipline
| Violation | Principle |
|-----------|-----------|
| TTL-less edge cache under partition (stale-forever, no explicit invalidation) | P6 Cache Invalidation is Explicit |
| Cache key that varies on untrusted query parameters (poisonable) | P6, P9 (`domains/security/input-validation`) |
| All-cache purge as the steady-state invalidation path (origin stampede) | P6, C8 Economy |
| Non-normalized cache key (case-sensitive host, unsorted query) | P6, `domains/security/input-validation` |
| Bypass-the-cache for cacheable content | P1 Proximity is the Design Driver (defeats the CDN) |
| Multi-CDN with no per-CDN purge completion tracking | P8, P10 (stale cache invisible to the operator) |
| Origin fetch that is not idempotent under retry | P5 Edge Operations are Idempotent |
| Cache-hit ratio not measured | P10, `domains/observability/metrics` |
| Single-CDN deployed where geographic distribution requires multi-CDN | P8 Geographic Distribution |
| Shield PoP that 500s instead of serving stale under partition | P7 Partial Degradation is Engineered |
+218
View File
@@ -0,0 +1,218 @@
# Edge — First Principles
## 1. The Principles
### P1. Proximity is the Design Driver
Compute, storage, and data are placed near the user or the data
source. At the edge, latency is a correctness constraint (C1), not a
performance preference — a late answer is a wrong answer when the
round trip to a central region exceeds the user's or device's
tolerance. This is the geographic expression of `C4 Locality`:
performance's locality is algorithmic (data near compute); edge's
locality is geographic (compute near user/data source). Placement is
a design decision, not an accident of deployment, and it is
constrained by `P8 Geographic Distribution`. The proximity angle is
the distinguishing trait of the edge domain per D-061: this is what
separates edge from `domains/performance/` (which owns *generic*
measurement and optimization, not placement).
### P2. Offline is a First-Class State
The system continues to operate when disconnected from the center.
Partition is the norm, not the exception; reconciliation happens on
reconnect, never assumed to be instant. An app that crashes on
disconnect has no offline state and is unengineered. Offline
operation derives from `C5 Reversibility` — the disconnected state
is reversible back to consistency via reconciliation — and `C1
Correctness`, because correctness under partition is the contract,
not eventual correctness as a hedge. This is the foundation for
`domains/edge/offline-first.md` and the precondition for the
bounded-conflict discipline of `P4`.
### P3. Resources are Constrained and Declared
Edge nodes — IoT sensors, gateways, point-of-sale devices, CDN PoP
caches, 5G MEC nodes — have bounded CPU, memory, power, and
bandwidth. Constraints are declared per node class, never assumed
infinite. An undeclared budget is a defect: unbounded growth is a
bug, and a constrained device with no budget will OOM or exhaust
power. This derives from `C8 Economy` (use no more than the task
requires) and `C1 Correctness` (a node that exceeds its bounds has
failed). This is the edge-specific angle on `domains/performance/P4
Resource Bounds` — performance owns the generic principle; edge owns
the constrained-device reality. See `domains/edge/iot.md` for the
per-device-class application.
### P4. Sync Conflicts are Bounded, Not Infinite
Divergent state across partitioned nodes converges. Oscillation and
infinite sync loops are correctness failures, not eventual
consistency. A merge that never terminates is a livelock; a CRDT
without merge semantics or an LWW without a monotonic clock can
oscillate forever. This derives from `C1 Correctness` (convergence is
a correctness contract) and `C5 Reversibility` (divergent state is
reversible back to convergence). The bound may be eventual (CRDTs) or
arbitrated (LWW with vector clocks), but it must exist. This is the
foundation for `domains/edge/sync.md` and the rule the
`edge-sync-loop` chaos anti-pattern breaches.
### P5. Edge Operations are Idempotent
Sync, cache fill, and device commands are retried by nature — the
network is partition-prone and the operation will be re-attempted.
Idempotency keys (or deterministic operations) make retries safe. A
non-idempotent edge write retried with side effects doubles the
effect; a non-idempotent cache fill under retry corrupts the cache.
This derives from `C1 Correctness`: correctness under retry is the
contract, not a nice-to-have. This parallels `domains/messaging/P3
Consumers are Idempotent` (cross-process delivery) and is the edge's
device-and-cache-flavored analog — see `domains/edge/iot.md` for
device command idempotency and `domains/edge/cdn.md` for cache-fill
idempotency.
### P6. Cache Invalidation is Explicit
Edge caches carry a defined invalidation or TTL strategy. A
stale-forever cache under partition is a silent correctness defect;
a TTL-less cache with no explicit invalidation is a bug, not a
feature. This derives from `C1 Correctness` (cached state must be
correct) and `C3 Simplicity` (a defined invalidation strategy is
simpler and clearer than ad-hoc staleness). This is distinct from
`domains/performance/P5 Caching with Intent`, which owns *generic*
caching and optimization; edge owns the *geographic,
partition-aware* invalidation angle — when a PoP is partitioned from
the origin, the invalidation strategy is the correctness mechanism.
See `domains/edge/cdn.md` for purge strategies (URL vs soft vs
surrogate-key) and the edge-cache-vs-origin decision matrix.
### P7. Partial Degradation is Engineered
The system degrades gracefully when an edge node or link fails. A
partial service is a designed state with a defined contract, not a
crash. One node's failure must not collapse the whole fleet; the
degraded mode is documented, observable, and recoverable. This
derives from `C1 Correctness` (the degraded contract is a
correctness bound) and `C5 Reversibility` (recovery from degradation
is reversible by construction). A crash-on-node-failure system has
no degradation contract — it has an all-or-nothing failure mode that
violates the fleet assumption. See `domains/edge/iot.md` for
device-drop degradation and `domains/edge/offline-first.md` for
partition degradation.
### P8. Geographic Distribution is a First-Class Constraint
The fleet is geo-distributed; routing, fan-out, and data placement
are location-aware decisions, not accidents of deployment. The
system is many nodes across many locations, not a single deployment.
Data residency, regional latency, and PoP selection are engineered,
not discovered in production. This derives from `C4 Locality` (the
placement of data and compute is a locality decision) and `C6
Composability` (the fleet composes from location-aware parts, each
with its own contract). This is the structural companion to `P1
Proximity`: P1 says *where* compute should be (near the user); P8
says the *distribution* of compute across geographies is a
first-class constraint. See `domains/edge/cdn.md` for multi-CDN
routing.
### P9. Identity is Constrained at the Edge
Edge devices and nodes hold scoped, minimal credentials. No edge
node is a cluster-admin-equivalent; device identity is per-device,
not shared. One compromise must not equal a fleet compromise. This
derives from `C1 Correctness` (security is a subset of correctness —
an exploitable edge node does not do what it was supposed to do) and
`C8 Economy` of trust (the credential scope is minimal for the task).
A shared edge-device credential is the edge analog of a
cluster-admin GitOps robot — blast radius is unbounded. See
`domains/security/secrets.md` for the general secret-hygiene
principles and `domains/edge/iot.md` for device provisioning.
### P10. Edge Observability Survives Partition
Telemetry is local-first: buffered on the node and forwarded on
reconnect. Partition does not blind the operator. A fire-and-forget
telemetry pipeline loses data when the link drops; a local-first
buffer survives. This derives from `C7 Observability` (the fleet's
behavior is visible to the operator) and `C5 Reversibility` (the
buffered telemetry is reversible back to visibility on reconnect).
This is distinct from `domains/observability/P1 Structured by
Default`, which owns *generic* structured telemetry; edge owns the
*partition-survivable, local-first* angle. See
`domains/observability/metrics.md` and
`domains/observability/logging.md` for the generic structured-
telemetry foundations edge builds on.
## 2. Core Principle Trace
Each edge P-rule derives from one or more core C-rules (C1C8). The
matrix extension lands in P4 of the v0.4 plan; the traces below are
authoritative. Edge is a broad-derivation domain touching 7 of 8
core principles (C1, C3, C4, C5, C6, C7, C8); C2 (Clarity) is not a
primary derivation — edge clarity is indirect (a cache with explicit
invalidation is clearer than one without, but the primary trace is
C1/C3).
| P-rule | Core | Why |
|--------|------|-----|
| P1 Proximity is the Design Driver | C4, C1 | Locality of compute near user/data; correctness via latency |
| P2 Offline is a First-Class State | C1, C5 | Correctness under partition; reversibility of reconciliation |
| P3 Resources are Constrained and Declared | C8, C1 | Economy of constrained nodes; correctness of declared bounds |
| P4 Sync Conflicts are Bounded, Not Infinite | C1, C5 | Correctness of convergence; reversibility of divergent state |
| P5 Edge Operations are Idempotent | C1 | Correctness under retry |
| P6 Cache Invalidation is Explicit | C1, C3 | Correctness of cached state; simplicity of defined invalidation |
| P7 Partial Degradation is Engineered | C1, C5 | Correctness of degraded modes; reversibility of recovery |
| P8 Geographic Distribution is a First-Class Constraint | C4, C6 | Locality of placement; composability of the fleet |
| P9 Identity is Constrained at the Edge | C1, C8 | Correctness via security; economy of trust |
| P10 Edge Observability Survives Partition | C7, C5 | Observability of the fleet; reversibility of buffered telemetry |
## 3. What Violates These Principles
| Violation | Principle Breached |
|-----------|-------------------|
| Central-region-only deployment for a latency-bound workload | P1 Proximity is the Design Driver |
| App that crashes on disconnect (no offline state) | P2 Offline is a First-Class State |
| Undeclared edge-node resource budget (assumes infinite CPU/memory) | P3 Resources are Constrained and Declared |
| Sync loop that oscillates forever (CRDT without merge-semantics, LWW without monotonic clock) | P4 Sync Conflicts are Bounded, Not Infinite |
| Non-idempotent edge write (cache-fill or device command retried with side effects) | P5 Edge Operations are Idempotent |
| TTL-less edge cache under partition (stale-forever, no explicit invalidation) | P6 Cache Invalidation is Explicit |
| Crash-on-node-failure (no partial-degradation contract) | P7 Partial Degradation is Engineered |
| Random geographic placement (no location-aware routing) | P8 Geographic Distribution is a First-Class Constraint |
| Shared edge-device credential (one key for the whole fleet) | P9 Identity is Constrained at the Edge |
| Fire-and-forget telemetry (no on-node buffer; data lost on partition) | P10 Edge Observability Survives Partition |
| Blocking call on a constrained IoT device with no timeout | P3, P5 (blocks the node; retry unsafe without idempotency) |
| Multi-CDN routing with no PoP-selection logic (latency uncontrolled) | P8, P1 (placement not a design decision) |
## 4. Relationship to Other Domains
Edge computing is the engineering discipline of placing compute,
storage, and data **near the source of generation or consumption**
rather than in a centralized cloud. The distinguishing constraints are
latency-bound operation, resource-constrained nodes,
geo-distribution as a fleet, and partition-prone operation. Edge
overlaps three existing domains by *subject* but not by *angle*: per
D-061, edge owns the proximity/location/constraint/disconnection
concerns that only arise at the network edge. The C4 Locality
emphasis is the discriminator: performance's locality is algorithmic
(data near compute); edge's locality is geographic (compute near
user/data source). Cross-links are one-directional outward (per
D-026 extended); no back-link edits to v0.1/v0.2/v0.3 content.
- `domains/performance/frontend` ← P6 (edge owns geographic,
partition-aware cache invalidation; performance owns *generic*
caching and measurement — D-061 boundary)
- `domains/performance/P4 Resource Bounds` ← P3 (edge owns
constrained-device reality; performance owns the generic
unbounded-growth-is-a-bug principle)
- `domains/observability/metrics` ← P10 (cache-hit ratio, edge
telemetry aggregation; edge owns the local-first angle, observability
owns generic structured metrics)
- `domains/observability/logging` ← P10 (local-first logging buffered
on-node and forwarded on reconnect)
- `domains/concurrency/patterns` ← P5 (the offline write-queue is
the cross-partition analog of the in-process bounded buffer —
concurrency owns in-process; edge owns partition-survivable)
- `domains/security/secrets` ← P9 (device credentials are scoped,
per-device, never shared — edge owns the constrained-identity
angle; security owns the general secret hygiene)
- `domains/security/input-validation` ← P6 (cache poisoning
prevention — edge cache keys are a validation surface)
- `domains/data/migrations` ← P4 (schema migration under sync must
reconcile across partitioned nodes; data owns the generic migration
discipline, edge owns the partitioned-reconcile angle)
> Note: cross-links to `domains/messaging/` (e.g., MQTT QoS parallels
> for delivery semantics) are intentionally omitted here — the
> messaging domain is authored in P2. The intra-v0.4 edge↔messaging
> links are added in P5 (ATELIER-114 per IDEATE-40) once both
> domains exist; the dangling link is acceptable per D-053.
+258
View File
@@ -0,0 +1,258 @@
# IoT — Derived Rules
> Derives from `domains/edge/first-principles.md`. Applies P3
> (Resources are Constrained and Declared) primarily, with P5
> (command idempotency), P7 (partial degradation when devices drop),
> P9 (device identity and provisioning), and P10 (telemetry from
> devices). Cross-links `domains/security/secrets` for device
> credentials and `domains/messaging/queues` for the MQTT QoS
> parallels to delivery semantics.
## What IoT at the Edge Is (P3 Resources are Constrained and Declared)
- IoT at the edge is the engineering discipline of operating
constrained devices — sensors, actuators, gateways, microcontrollers
— as first-class participants in a distributed system. The
distinguishing constraint is per-device resource bounds (P3): a
battery-powered sensor has kilobytes of RAM, a constrained
protocol, and a multi-year sleep budget. These constraints are
declared per device class, never assumed infinite.
- The boundary is per D-061: edge owns the constrained-device
reality; performance owns the generic unbounded-growth-is-a-bug
principle (`performance/P4 Resource Bounds`); concurrency owns
in-process primitives. IoT is an edge concern because its defining
traits are constrained resources (P3), geographic distribution as
a fleet (P8), partition-prone operation (P2), and device-scoped
identity (P9) — concerns that only arise at the network edge.
- See `domains/edge/offline-first.md` for the partition-survival
discipline that constrained devices depend on, and
`domains/edge/sync.md` for the reconciliation of device state
across partitions.
## Device Resource Classes (P3, C8 Economy)
- A device resource class declares the bounds for a class of
devices: CPU (MHz, cores), memory (KB/MB), power (battery mAh,
duty-cycle budget), bandwidth (bytes/sec, latency budget), and
storage (KB/MB). Every device in the fleet is assigned to a class;
every operation is budgeted against its class.
- An undeclared budget is a defect (P3 violation): a sensor that
sends telemetry every second without a duty-cycle budget exhausts
its battery in days, not years. The budget is the correctness
bound (C1) and the economy bound (C8).
- A device class implies a protocol choice: a class-0 device
(constrained sensor, KB RAM) speaks CoAP; a class-1 device
(gateway, MB RAM) speaks MQTT; a class-2 device (edge compute
node, GB RAM) speaks HTTP. The protocol follows the constraint,
not the reverse.
| Class | RAM | Power | Protocol | Typical role |
|-------|-----|-------|----------|--------------|
| 0 (constrained sensor) | < 10 KB | Battery, multi-year | CoAP, LoRaWAN | Telemetry only, no inbound commands |
| 1 (actuator, gateway) | 10 KB 1 MB | Battery or wired, weeks-months | MQTT, CoAP | Telemetry + commands, queue-and-forward |
| 2 (edge compute) | > 1 MB | Wired, continuous | HTTP, MQTT | Local aggregation, gateway, edge inference |
## Constrained Protocols — MQTT and CoAP (P3, P5)
- **MQTT** is the canonical pub/sub protocol for constrained devices.
It is lightweight (2-byte header), broker-backed, and provides QoS
levels (0, 1, 2) that map to delivery semantics. MQTT is the
cross-process analog of message-queue delivery — see
`domains/messaging/queues` for the general queue/delivery-semantics
discipline; the cross-link is one-directional outward (edge →
messaging) per D-062 and D-026 extended.
- **CoAP** is the REST analog for constrained devices: UDP-based,
low-overhead, with confirmable (CON) and non-confirmable (NON)
message types. CoAP fits class-0 devices where TCP is too heavy.
- A blocking synchronous call on a constrained device with no
timeout is the `blocking-call-on-constrained-device` chaos
anti-pattern: it blocks the node, has no timeout (= hang), and
retries are unsafe without idempotency (P3 + P5 breach). Every
device operation must be async with a timeout, and every retried
operation must be idempotent.
```json
// MQTT publish/subscribe payload with QoS levels (P5 idempotency,
// P3 constrained protocol).
// QoS 0 — at-most-once: fire-and-forget, no ack. For telemetry
// where a dropped sample is acceptable (P3 economy of the
// constrained link).
{
"topic": "devices/sensor-7/temperature",
"qos": 0,
"payload": {
"device": "sensor-7",
"ts": 1700000000,
"value": 21.4,
"unit": "C"
}
}
// QoS 1 — at-least-once: acked, may duplicate. The consumer must
// be idempotent (P5) — dedup by (device, ts) or an idempotency key.
{
"topic": "devices/actuator-3/command",
"qos": 1,
"payload": {
"device": "actuator-3",
"idempotencyKey": "cmd-1700000000-1",
"command": "set-point",
"value": 22.0
}
}
// QoS 2 — exactly-once: four-step handshake, no duplication. The
// heaviest QoS; use only where duplicates are intolerable AND the
// device has the budget for the handshake (class-1+ only, P3).
{
"topic": "devices/actuator-3/irreversible-command",
"qos": 2,
"payload": {
"device": "actuator-3",
"idempotencyKey": "cmd-1700000000-2",
"command": "calibrate"
}
}
```
- The QoS choice is a P5 (idempotency) and P3 (resource) decision:
QoS 0 is cheapest (no ack) but lossy; QoS 1 requires consumer
idempotency (P5); QoS 2 is exactly-once but costs a four-step
handshake on a constrained link. The default for telemetry is QoS
0 or 1; the default for commands is QoS 1 with an idempotency key
(P5); QoS 2 is reserved for irreversible commands where the
device budget permits.
## Device Identity and Provisioning (P9 Identity is Constrained at the Edge)
- Every device holds a per-device identity: a unique device ID and a
scoped credential (X.509 certificate, API token, or rotating
key). No shared fleet credential — one compromise must not equal
a fleet compromise (P9). The credential scope is minimal: a
device can publish to `devices/<its-id>/+` and subscribe to
`devices/<its-id>/commands`, nothing else.
- Provisioning is the act of assigning a device identity at
enrollment time. The provisioning manifest declares the device,
its class, its allowed topics, and its credential. The manifest is
the P9 contract — a device operating outside its manifest scope is
a violation.
- A device that is provisioned with a shared fleet key (the
`shared-edge-device-credential` anti-pattern) is a P9 violation:
blast radius is unbounded. See `domains/security/secrets` for the
general secret-hygiene discipline (per-identity credentials,
rotation, minimal scope) that device provisioning builds on.
```yaml
# Device provisioning manifest (P9 per-device identity + scoped
# credentials). The manifest is the contract; the device operates
# only within its declared scope.
device:
id: sensor-7
class: 0 # P3 resource class
model: temp-sensor-v2
firmware: 1.4.2
identity:
cert: "sha256-of-device-cert"
credentialScope:
publish:
- "devices/sensor-7/temperature"
- "devices/sensor-7/status"
subscribe:
- "devices/sensor-7/commands"
# No wildcard, no fleet-wide topics (P9).
provisioning:
enrolledAt: 2024-01-15T00:00:00Z
rotatesEvery: 90d
# Per-device credential; never shared (P9, domains/security/secrets).
```
## Telemetry from Devices (P10 Edge Observability Survives Partition)
- Device telemetry is local-first (P10): the device buffers telemetry
on-node and forwards on reconnect. A fire-and-forget telemetry
pipeline loses data when the link drops; a buffered pipeline
survives. The buffer is bounded by the device class (P3): a
class-0 sensor buffers minutes of telemetry, not hours.
- Telemetry is observable in aggregate: the operator sees the fleet's
behavior, not just per-device. A device that has not reported in
its expected interval is itself a signal (a dead device, a
partitioned device, a drained battery). See
`domains/observability/metrics` for the generic structured-metrics
discipline; edge owns the partition-survivable, local-first angle.
- Telemetry must not be a secrets channel (P9 analog, see
`domains/observability/P6 No Secrets in Observability`): device
credentials, PII, and personally-identifying location must not
enter telemetry payloads.
## Command Idempotency (P5 Edge Operations are Idempotent)
- Device commands are retried by nature (the network is
partition-prone). Every command carries an idempotency key so a
retried command does not double-apply (P5). A `set-point` command
retried with the same idempotency key sets the point once, not
twice; an `open-valve` command retried is safe because the valve
is already open.
- Irreversible commands (a calibration burn-in, a firmware flash)
require stronger idempotency: the device tracks applied
idempotency keys and refuses re-application. A retried irreversible
command without idempotency tracking double-applies the effect
(P5 violation, possibly a physical-side-effect bug).
- The idempotency key is per-command, not per-device. A device that
dedups by device ID alone will drop distinct commands issued in
the same window. Use `(device, command-id, ts-window)` or a
UUID per command.
## Partial Degradation When Devices Drop (P7 Partial Degradation is Engineered)
- A fleet degrades when devices drop (battery exhaustion, partition,
hardware failure). The system must continue to operate with the
remaining devices; a whole-system crash on one device's failure is
a P7 violation. The degraded mode is documented: which
aggregations are valid with N-1 devices, which alerts fire, which
fallbacks engage.
- A device that drops is not an incident by itself — fleets expect
churn. The operator-facing signal is the *aggregate* health (X%
of devices reporting, Y% partitioned for >Z minutes), not the
per-device drop. Per-device drop alerts are noise; aggregate
degradation alerts are signal (see `domains/observability/metrics`).
- A command to a dropped device must time out (P5 — idempotent
retry) and degrade (P7 — the fleet continues without that
device). A command that blocks forever waiting for a dropped
device is the `blocking-call-on-constrained-device` chaos
anti-pattern (P3 + P5 breach).
## Cross-Link to Messaging (P5, cross-link messaging/queues)
- MQTT QoS 0/1/2 maps to at-most-once / at-least-once / exactly-once
delivery semantics — the same three-way tradeoff documented in
`domains/messaging/queues`. The cross-link is one-directional
outward (edge → messaging) per D-026 extended: edge owns the
constrained-device protocol angle; messaging owns the generic
cross-process delivery-semantics angle.
- This link dangles until P2 (the messaging domain is authored in
P2); it is verified bidirectional in P5 (ATELIER-114 per
IDEATE-40). Acceptable per D-053 (vertical-slice integrity — P1
ships the edge domain self-consistent; the messaging cross-link
resolves by the P6 ship).
- The parallel: a constrained device's QoS 1 publish is the
device-flavored instance of an at-least-once queue delivery — the
consumer (the broker or the downstream service) must be
idempotent (P5 here, `messaging/P3 Consumers are Idempotent`
there). The idempotency discipline is the same; the protocol and
failure model differ (constrained-device link vs broker-backed
network).
## What Violates IoT-at-the-Edge Discipline
| Violation | Principle |
|-----------|-----------|
| Undeclared device resource budget (assumes infinite battery/RAM) | P3 Resources are Constrained and Declared |
| Shared fleet credential (one key for all devices) | P9 Identity is Constrained at the Edge |
| Non-idempotent device command (retried command doubles the effect) | P5 Edge Operations are Idempotent |
| Blocking synchronous call on a constrained device with no timeout | P3, P5 (blocks the node; retry unsafe) |
| Fire-and-forget telemetry with no on-device buffer (lost on partition) | P10 Edge Observability Survives Partition |
| Whole-system crash on one device's failure (no degradation contract) | P7 Partial Degradation is Engineered |
| Device credential scope that includes fleet-wide topics (over-scoped) | P9, `domains/security/secrets` |
| QoS 2 used on a class-0 device (no budget for the handshake) | P3 Resources are Constrained and Declared |
| Per-device-drop alert (noise; aggregate degradation is the signal) | P7, `domains/observability/metrics` |
| Telemetry payload that includes device credentials or PII | P9, `domains/observability/P6 No Secrets in Observability` |
+354
View File
@@ -0,0 +1,354 @@
# Offline-First — Derived Rules
> Derives from `domains/edge/first-principles.md`. Applies P2
> (Offline is a First-Class State) primarily, with P5 (idempotent
> queue-and-forward), P4 (bounded sync conflicts on reconnect), P7
> (partial degradation), and P10 (local-first telemetry). Cross-links
> `domains/concurrency/patterns` for the in-process bounded-buffer
> analog and `domains/observability/logging` for local-first logging.
## What Offline-First Is (P2 Offline is a First-Class State)
- Offline-first is the design discipline in which the system
continues to operate when disconnected from the center. Partition
is the norm, not the exception; reconciliation happens on
reconnect. The offline state is engineered, not a degenerate mode
the app falls into by accident.
- The boundary is per D-061: edge owns the
proximity/location/disconnection angle. An offline-first web app
is an edge concern because its defining trait is partition-survival
(P2), not generic performance. The local-first storage is the
edge device's constrained-resource reality (P3).
- Offline-first is the precondition for the bounded-conflict
discipline of `P4 Sync Conflicts are Bounded, Not Infinite`:
without offline operation there is nothing to reconcile; with it,
the reconnect reconciliation is the correctness mechanism. See
`domains/edge/sync.md` for the conflict-resolution strategies.
## Local-First Storage (P2, P3)
- Local-first storage holds the working copy on the device:
IndexedDB (browser), SQLite (mobile, embedded), or on-device file
storage (desktop, IoT gateway). The local store is the authority
while offline; the server is reconciled later, not consulted per
read.
- The local store is bounded by the device (P3 — Resources are
Constrained and Declared). A local store that grows without bound
is a defect: declare a budget (e.g., a 50 MB IndexedDB quota, a
30-day rolling window), and evict outside the budget deterministically.
- The local store is the offline state; without it the app is
online-only and crashes on disconnect (P2 violation). The store is
the reversibility mechanism (C5): every local write is reversible
on reconcile.
```typescript
// Local-first store sketch (IndexedDB). The app reads from the
// local store, never the network, while offline. Writes queue
// locally and forward on reconnect (P2, P5).
const db = await openDB("atelier-offline", 1, {
upgrade(db) {
const store = db.createObjectStore("pending-writes", {
keyPath: "id",
});
store.createIndex("by-createdAt", "createdAt");
},
});
async function readRecord(id: string) {
// Read from local store first; the network is a reconcile path,
// not the read path.
return db.get("pending-writes", id);
}
```
## Queue-and-Forward for Writes (P5 Edge Operations are Idempotent)
- Every write while offline is queued locally and forwarded to the
server on reconnect. The queue is the offline write-queue; the
forward is the reconcile. Each queued write carries an idempotency
key so a retried forward (the network is partition-prone) does not
double-apply (P5).
- The queue is bounded (P3): a queue that grows without limit on a
constrained device will exhaust it. Declare a max-queue-depth and
a max-queue-bytes; reject or evict beyond the bound with a defined
policy (oldest-first, lowest-priority-first).
- The queue is the cross-partition analog of the in-process bounded
buffer — see `domains/concurrency/patterns` (bounded buffer,
backpressure). Concurrency owns the in-process analog; edge owns
the partition-survivable analog. The failure model differs: the
in-process buffer fails by OOM; the offline write-queue fails by
partition or device loss.
```typescript
// Offline write-queue sketch. Each entry carries an idempotency
// key (P5) so a retried forward is safe. The queue is bounded by
// maxDepth (P3).
interface PendingWrite {
id: string; // local id
idempotencyKey: string; // server-side dedup key (P5)
collection: string;
payload: unknown;
createdAt: number;
}
const MAX_DEPTH = 1000;
async function queueWrite(write: Omit<PendingWrite, "id" | "idempotencyKey" | "createdAt">) {
const depth = await db.count("pending-writes");
if (depth >= MAX_DEPTH) {
// P3: bounded queue. Evict the oldest pending write or reject.
// Rejecting is correct when the write is higher-priority than
// the oldest; evicting is correct when the newest is lowest.
throw new Error("offline-queue-full");
}
const entry: PendingWrite = {
...write,
id: crypto.randomUUID(),
idempotencyKey: `${write.collection}:${crypto.randomUUID()}`,
createdAt: Date.now(),
};
await db.put("pending-writes", entry);
// The forward loop picks this up when connectivity returns.
}
async function forwardPendingWrites(server: Server) {
const pending = await db.getAllFromIndex("pending-writes", "by-createdAt");
for (const write of pending) {
// P5: idempotent — the server dedups by idempotencyKey.
await server.apply(write, write.idempotencyKey);
await db.delete("pending-writes", write.id);
}
}
```
## Conflict Detection on Reconnect (P4 Sync Conflicts are Bounded)
- On reconnect, the queued writes are forwarded; the server may
have advanced while the device was offline. A conflict is when the
local write and the server state diverge. Conflict detection is
the precondition for bounded reconciliation (P4): a write forwarded
blindly (last-write-wins with no clock) is a P4 violation waiting
to happen.
- Conflict resolution strategies (CRDT, LWW with vector clocks,
application-specific merge) are the subject of
`domains/edge/sync.md` — the CRDT-vs-LWW decision matrix there
determines which applies. Offline-first owns the *detection*; sync
owns the *resolution*.
- A reconnect that detects no conflicts when conflicts exist is a
silent correctness defect (C1, P4). Detection must be conservative:
when in doubt, flag a conflict and surface it to the merge
function or the user.
## UI for Offline State (P7 Partial Degradation is Engineered)
- The UI must reflect the offline state visibly: a "you are offline,
changes will sync when connected" banner, a pending-writes counter,
a last-synced timestamp. A UI that hides the offline state
violates P7 — the degraded mode is a designed state with a defined
contract, not a silent fall-through.
- The UI must function while offline: reads from local-first
storage, writes to the queue, navigation that does not require the
network. An app that shows a blank screen or a spinner-forever when
offline has no offline state (P2 violation) and no degradation
contract (P7 violation).
- The pending-writes counter is the local-first analog of the
messaging consumer-lag metric — see
`domains/observability/metrics` for the lag-discipline parallel.
## Service Workers (P2, P6)
- A service worker is a client-side proxy that intercepts network
requests and serves from a local cache. It is the browser's
offline-first primitive: the service worker cache is the
offline-capable store for assets; the IndexedDB store is the
offline-capable store for data.
- The service worker cache is an edge cache (P6 — Cache Invalidation
is Explicit): it must carry a TTL or explicit invalidation
strategy. A service worker that caches forever and never
invalidates is a TTL-less edge cache under partition — a P6
violation (stale-forever).
- See `domains/edge/cdn.md` for the generic edge-cache invalidation
discipline; the service worker is the on-device instance of it.
```javascript
// Service worker cache strategy: stale-while-revalidate for
// assets, network-first for data, explicit version-bump for
// breaking changes (P6).
const CACHE = "atelier-v3"; // bump on deploy to invalidate (P6)
const ASSETS = ["/", "/app.js", "/styles.css"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll(ASSETS))
);
});
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.pathname.startsWith("/api/")) {
// Network-first for data; fall back to cache on partition (P2).
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
);
} else {
// Stale-while-revalidate for assets (P6 explicit invalidation).
event.respondWith(
caches.open(CACHE).then(async (cache) => {
const cached = await cache.match(event.request);
const network = fetch(event.request).then((resp) => {
cache.put(event.request, resp.clone());
return resp;
}).catch(() => cached);
return cached || network;
})
);
}
});
self.addEventListener("activate", (event) => {
// P6: explicit invalidation. Drop old caches on activate.
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
)
);
});
```
## Offline Write-Queue and Conflict Detection Mapped to the Testing Pyramid (IDEATE-38, ATELIER-94)
The offline write-queue and conflict-detection patterns must be
tested at every tier of the testing pyramid. Each tier exercises a
different failure mode; skipping a tier leaves a correctness gap
(P2, P4 violations that surface only in production partitions).
| Pyramid Tier | What it exercises | What it proves |
|-------------|-------------------|----------------|
| **Unit** | Conflict detection on a merge function (pure inputs → expected merge result) | The merge logic is correct in isolation (P4) — given two divergent states, the merge returns the converged state |
| **Integration** | Reconnect reconcile against a local store (fake server, real IndexedDB/SQLite) | The queue-and-forward loop drains correctly; the local store and server converge after reconnect (P2, P5) |
| **E2e** | Partition simulation with a fake network (the app runs in a browser, the network is cut and restored) | The offline state, UI, and reconcile work end-to-end under partition (P2, P7) |
- **Unit — conflict detection on a merge function.** The merge
function is pure: given two divergent states and a clock, it
returns the converged state. Test every merge case (LWW, CRDT
register, set union, application-specific three-way merge) as a
pure function. This is the cheapest tier and the highest coverage
per test — see `domains/testing/pyramid`.
```typescript
// Unit test sketch: conflict detection on a merge function (P4).
// The merge function is pure; no network, no store. Test that
// divergent states converge and that the merge is bounded (no
// oscillation).
function mergeLWW(local: State, remote: State, clock: Clock): State {
// Last-write-wins: the state with the later vector-clock wins.
// Returns the converged state (P4).
return clock.compare(local.clock, remote.clock) >= 0 ? local : remote;
}
// Unit cases:
// - local ahead → local wins
// - remote ahead → remote wins
// - concurrent (clocks incomparable) → conflict flagged or LWW tiebreak
// - identical → no-op convergence (bounded, no oscillation)
test("mergeLWW converges when local is ahead", () => {
const local = { v: 2, clock: { a: 2 } };
const remote = { v: 1, clock: { a: 1 } };
expect(mergeLWW(local, remote, { compare: (a, b) => a.a - b.a })).toEqual(local);
});
```
- **Integration — reconnect reconcile against a local store.** A
fake server stands in for the network; the real IndexedDB (or
SQLite) holds the queue. The test fills the queue while offline,
reconnects, and asserts the queue drains and the server and local
store converge. This exercises the queue-and-forward loop (P5)
and the reconcile against real storage.
```typescript
// Integration test sketch: reconnect reconcile against a local
// store. A fake server; real IndexedDB. The queue drains; the
// server and local store converge after reconnect (P2, P5).
test("reconnect reconciles pending writes against the server", async () => {
const db = await openDB("test-offline", 1, { /* schema */ });
const server = new FakeServer();
await queueWrite(db, { collection: "docs", payload: { v: 1 } });
// Simulate offline: server is unreachable.
server.offline();
await queueWrite(db, { collection: "docs", payload: { v: 2 } });
expect(await db.count("pending-writes")).toBe(2);
// Simulate reconnect: server is reachable.
server.online();
await forwardPendingWrites(server, db);
expect(await db.count("pending-writes")).toBe(0);
expect(await server.latest("docs")).toEqual({ v: 2 });
});
```
- **E2e — partition simulation with a fake network.** The app runs
in a real browser; a fake network layer cuts and restores the
connection. The test asserts the UI shows the offline state, the
writes queue, the reconnect reconciles, and the UI returns to
online. This is the highest-fidelity tier and the lowest coverage
per test — run a small number of representative scenarios, not a
combinatorial matrix.
```typescript
// E2e test sketch: partition simulation with a fake network. The
// app runs in a browser; the network is cut and restored. Asserts
// the offline UI state, the queue, the reconcile, and the online
// recovery (P2, P7).
test("app survives a network partition and reconciles on reconnect", async () => {
await page.goto("https://app.example.com");
await page.click("text=Edit document");
await page.fill("textarea", "offline edit");
// Cut the network.
await page.setOffline(true);
await page.click("text=Save");
await expect(page.locator("text=You are offline")).toBeVisible();
await expect(page.locator("text=1 pending change")).toBeVisible();
// Restore the network.
await page.setOffline(false);
await expect(page.locator("text=All changes synced")).toBeVisible();
await expect(page.locator("text=0 pending changes")).toBeVisible();
});
```
- The three tiers are complementary: unit proves the merge logic,
integration proves the reconcile loop, e2e proves the partition
behavior. Skipping any tier leaves a correctness gap. See
`domains/testing/pyramid` for the pyramid discipline and
`domains/testing/fixtures` for the fake-server and fake-network
fixture patterns.
## Observability (P10 Edge Observability Survives Partition)
- The offline state is itself an observable signal: the
pending-writes count, the last-synced timestamp, the
reconcile-failure count. A device stuck offline for days with a
full queue is an incident; without local-first telemetry it is
invisible (P10 violation).
- Local-first logging (buffered on-device, forwarded on reconnect)
is the offline-first instance of `P10 Edge Observability Survives
Partition`. See `domains/observability/logging` for the generic
structured-logging discipline the local-first buffer builds on.
- A reconcile failure that is not logged locally is a silent defect
— the operator cannot debug what they cannot see (C7, P10).
## What Violates Offline-First Discipline
| Violation | Principle |
|-----------|-----------|
| App that crashes on disconnect (no offline state) | P2 Offline is a First-Class State |
| Unbounded offline write-queue (grows until device OOM) | P3 Resources are Constrained and Declared |
| Queued write forwarded without an idempotency key (retry doubles the effect) | P5 Edge Operations are Idempotent |
| Reconnect that detects no conflicts when conflicts exist | P4 Sync Conflicts are Bounded, Not Infinite |
| UI that hides the offline state (no banner, no pending counter) | P7 Partial Degradation is Engineered |
| Service worker cache with no TTL and no explicit invalidation | P6 Cache Invalidation is Explicit |
| Reconcile failure with no local log (silent under partition) | P10 Edge Observability Survives Partition |
| Merge function that oscillates (no convergence guarantee) | P4, `domains/edge/sync.md` |
| Local-first store with no declared budget (grows without bound) | P3, `domains/concurrency/patterns` (bounded buffer analog) |
| E2e tests that never simulate a partition (offline path untested) | P2, `domains/testing/pyramid` |
+315
View File
@@ -0,0 +1,315 @@
# Sync — Derived Rules
> Derives from `domains/edge/first-principles.md`. Applies P4 (Sync
> Conflicts are Bounded, Not Infinite) primarily, with P5
> (idempotent merge operations), P2 (offline as the precondition),
> and P10 (sync is observable). Cross-links `domains/data/migrations`
> for schema migration under sync and `domains/concurrency/patterns`
> for the immutability-aid-merge principle.
## What the Sync Problem Is (P4 Sync Conflicts are Bounded, Not Infinite)
- Sync is the discipline of reconciling divergent state across
partitioned nodes. While partitioned, each node accepts writes
independently; on reconnect, the divergent state must converge.
The correctness contract is that the merge terminates and
converges — oscillation and infinite sync loops are correctness
failures, not eventual consistency (P4).
- Sync is the edge domain's deepest problem: it is the
reconciliation layer above `P2 Offline is a First-Class State`.
Without offline operation there is nothing to sync; with it, the
reconnect reconciliation is the correctness mechanism. See
`domains/edge/offline-first.md` for the offline write-queue that
produces the divergent state to be reconciled.
- The boundary is per D-061: edge owns the partitioned-reconcile
angle; concurrency owns the in-process analog
(`concurrency/P1 Immutability by Default` — immutability aids
merge); data owns the generic migration discipline
(`data/migrations`). Sync is an edge concern because its defining
trait is partitioned divergence, a concern that only arises at the
network edge.
## Conflict Resolution Strategies (P4, C1 Correctness, C5 Reversibility)
- A conflict is when two nodes have divergent state for the same
logical entity and no total order determines which is correct.
Resolution strategies fall into two families:
- **Conflict-free**: the data type guarantees convergence by
construction (CRDTs). The merge is deterministic; no conflict
surfaces to the user or the application.
- **Conflict-tolerant**: the data type can conflict; the
resolution policy (last-write-win, three-way merge,
application-specific) arbitrates. Conflicts may surface to the
user or be silently resolved per a documented policy.
- The choice is a P4 decision: conflict-free types guarantee the
bound (convergence) but constrain the data model; conflict-tolerant
types are flexible but require the resolution policy to be correct
and bounded (no oscillation). See the decision matrix below.
## CRDTs — Conflict-Free Replicated Data Types (P4, C5, C6)
- A CRDT is a data type whose merge operation is associative,
commutative, and idempotent. Given any set of divergent replicas,
merging them in any order converges to the same state — the merge
is deterministic and terminating (P4 bound). CRDTs derive from C5
Reversibility (divergent state reverses to convergence) and C6
Composability (CRDTs compose: a CRDT map of CRDT registers is
itself a CRDT).
- **State-based (CvRDT — convergent):** each replica carries its
full state; merge is a least-upper-bound on a semi-lattice. The
payload is larger (full state per merge); the merge is simple
(one function). Fits small state and unreliable networks.
- **Operation-based (CmRDT — commutative):** each replica carries
operations; merge is applying the operations in causal order. The
payload is smaller (ops, not state); the delivery must be
reliable and causally ordered. Fits large state and reliable
transport.
- The tradeoff: state-based is simpler but heavier; operation-based
is lighter but requires causal delivery. Both guarantee
convergence (P4); the choice is a C8 Economy decision (bandwidth
vs delivery complexity).
```typescript
// CRDT register: LWW-element-set (state-based, CvRDT). The merge
// is deterministic — the register with the later timestamp wins.
// Convergence is guaranteed (P4); the merge is idempotent (P5).
interface LWWRegister<T> {
value: T;
timestamp: number; // monotonic clock; ties broken by node id
nodeId: string;
}
function mergeLWWRegister<T>(
local: LWWRegister<T>,
remote: LWWRegister<T>,
): LWWRegister<T> {
// The merge is associative, commutative, idempotent (P4, P5).
// (local.timestamp, local.nodeId) > (remote.timestamp, remote.nodeId)
// is a total order — no oscillation, no infinite loop.
if (local.timestamp > remote.timestamp) return local;
if (local.timestamp < remote.timestamp) return remote;
// Tie: break by node id for a deterministic total order.
return local.nodeId > remote.nodeId ? local : remote;
}
// The register is a CRDT: merge(merge(a, b), c) === merge(a, merge(b, c))
// for any replicas a, b, c. Convergence is guaranteed (P4).
```
```typescript
// CRDT set: add-wins last-write-wins element set (state-based).
// Each element carries a timestamp; remove only wins if the
// remove-timestamp is later than the add-timestamp. This avoids
// the remove-wins-vs-add race (P4) without surfacing a conflict.
interface AWLWWSet<T> {
adds: Map<T, number>; // element -> add-timestamp
removes: Map<T, number>; // element -> remove-timestamp
}
function mergeAWLWWSet<T>(a: AWLWWSet<T>, b: AWLWWSet<T>): AWLWWSet<T> {
const adds = new Map<T, number>(a.adds);
const removes = new Map<T, number>(a.removes);
for (const [el, ts] of b.adds) {
adds.set(el, Math.max(adds.get(el) ?? 0, ts)); // add-wins union
}
for (const [el, ts] of b.removes) {
removes.set(el, Math.max(removes.get(el) ?? 0, ts));
}
return { adds, removes };
}
function contains<T>(set: AWLWWSet<T>, el: T): boolean {
const addTs = set.adds.get(el) ?? 0;
const rmTs = set.removes.get(el) ?? 0;
return addTs > rmTs; // add wins on equal timestamp (P4 bounded)
}
```
## Last-Write-Win (LWW) with Vector Clocks (P4, C1, C5)
- LWW is the simplest conflict-tolerant strategy: the write with the
latest timestamp wins. It is cheap, but it silently discards
concurrent writes — the "lost update" is the correctness cost. LWW
is correct only when the timestamp is a total order (a monotonic
clock, not wall time), and when lost concurrent writes are
acceptable (e.g., caching, presence, ephemeral state).
- **Vector clocks** are the timestamp that knows about concurrency.
A vector clock records the logical time of each node; two writes
are concurrent iff neither vector dominates the other. LWW with
vector clocks: a write that is causally later wins; a write that
is concurrent conflicts and is resolved by a tiebreak (node id,
wall time, or application policy).
- The tiebreak is the P4 bound: the conflict must be resolved
deterministically (no oscillation) and the resolution must be
documented. A tiebreak by wall time alone (no vector clock) is a
P4 violation waiting to happen — wall time skews across nodes,
and a clock skew can flip the tiebreak, oscillating the merge.
```typescript
// LWW with vector clocks (conflict-tolerant, P4 bounded). The
// vector clock records causal order; concurrent writes conflict;
// the conflict is tiebroken deterministically (no oscillation).
type VectorClock = Record<string, number>; // nodeId -> counter
function compareClock(a: VectorClock, b: VectorClock): "before" | "after" | "equal" | "concurrent" {
let aBefore = false, bBefore = false;
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
for (const k of keys) {
const av = a[k] ?? 0;
const bv = b[k] ?? 0;
if (av < bv) aBefore = true;
if (av > bv) bBefore = true;
}
if (aBefore && bBefore) return "concurrent";
if (aBefore) return "before";
if (bBefore) return "after";
return "equal";
}
interface LWWVectorState<T> {
value: T;
clock: VectorClock;
writerId: string; // tiebreak: deterministic, no oscillation (P4)
}
function mergeLWWVector<T>(
local: LWWVectorState<T>,
remote: LWWVectorState<T>,
): LWWVectorState<T> {
const order = compareClock(local.clock, remote.clock);
if (order === "before") return remote; // remote causally later
if (order === "after" || order === "equal") return local;
// Concurrent: tiebreak by writer id (deterministic, P4 bounded).
return local.writerId > remote.writerId ? local : remote;
}
```
- The merge is idempotent (P5): merging the same two replicas twice
yields the same result. The tiebreak by `writerId` is a total
order, so the merge cannot oscillate (P4 bound).
- A vector-clock merge that surfaces the concurrent conflict to the
application (instead of tiebreaking) is also valid — the
application resolves per its own policy. The P4 bound is that the
resolution terminates; the policy determines whether the user sees
the conflict or the system silences it.
## Merge Semantics (P4, P5, cross-link concurrency/patterns)
- The merge function is the heart of sync. Its properties (P4):
- **Associative**: `merge(merge(a, b), c) === merge(a, merge(b, c))`.
- **Commutative**: `merge(a, b) === merge(b, a)`.
- **Idempotent**: `merge(a, a) === a` (P5 — retried merges are safe).
- Immutability aids merge: an immutable state representation (the
CRDT payload, the LWW register with a clock) makes the merge a
pure function of two inputs, with no in-place mutation race. See
`domains/concurrency/patterns` (`concurrency/P1 Immutability by
Default`) for the in-process immutability principle; sync is the
cross-partition instance of it.
- A merge that mutates in place is a P5 violation waiting to
happen: a retried merge mutates the same state twice, and the
result is not idempotent. Always merge into a new state; never
mutate the inputs.
## Conflict-Free vs Conflict-Tolerant Data Types (P4, C3 Simplicity)
- **Conflict-free (CRDTs):** the data type guarantees convergence.
The application never sees a conflict; the merge is deterministic.
The cost: the data model is constrained (counters, sets, registers,
maps of these). A conflict-free type for arbitrary JSON is hard;
a conflict-free type for a counter is a PN-counter.
- **Conflict-tolerant (LWW, three-way merge, application policy):**
the data type can conflict; the resolution policy arbitrates. The
cost: the policy must be correct and bounded (no oscillation), and
the conflict may surface to the user. The benefit: any data model
can be made conflict-tolerant (just pick a tiebreak).
- The choice is the decision matrix below. It is a P4 decision
(which bound), a C1 decision (which correctness cost is
acceptable), and a C3 decision (which simplicity is affordable).
See also `domains/data/migrations` for the schema-evolution angle
— a schema change under sync must be compatible with both
replicas, or the merge fails on the new shape.
## Schema Migration Under Sync (P4, cross-link data/migrations)
- A schema migration under sync is harder than a single-node
migration: both replicas must understand the new shape, or the
merge fails. The migration must be forward-and-backward compatible
across all replicas that may still hold the old shape — see
`domains/data/migrations` for the generic compatibility discipline.
- A breaking schema change under sync requires a staged migration:
deploy the new-shape-aware merge first (it accepts both shapes),
then deploy the new shape, then deploy the old-shape-removing
merge. A big-bang schema change under sync is a P4 violation: the
replicas that have not yet upgraded will fail the merge, and the
sync will not converge.
- The merge function's version awareness is the P4 bound: the merge
must handle every shape version that may exist in the fleet, or
reject (and surface) the merge rather than silently corrupting.
## CRDT vs Last-Write-Win — Decision Matrix (D-069)
| Strategy | When | Correctness Guarantee | Operational Cost | Failure Mode |
|----------|------|------------------------|-------------------|--------------|
| CRDT (state-based, CvRDT) | The data model fits a CRDT (counter, set, register, map of these); convergence must be guaranteed without surfacing conflicts; the network is unreliable (full-state merge tolerates dropped ops) | Strong eventual convergence — `merge(a, b) === merge(b, a)` for any replicas (P4 bound by construction) | Medium — full state per merge (bandwidth); semi-lattice merge function per type; CRDT library or hand-rolled | A bug in the merge function = silent divergence (C1); large state = bandwidth cost on constrained links (P3) |
| CRDT (operation-based, CmRDT) | The data model fits a CRDT; bandwidth is constrained (ops are smaller than state); the transport is reliable and causally ordered | Strong eventual convergence — same guarantee, smaller payload | High — requires causal delivery (vector clock or broker with ordering); op transform must be idempotent (P5) | Causal-delivery violation = lost ops = divergence; op-transform bug = silent divergence |
| Last-Write-Win (LWW) with vector clocks | The data model is arbitrary (any JSON, any record); concurrent writes are acceptable to discard or tiebreak; a total order tiebreak (node id) is acceptable | Bounded convergence — causally-later writes win; concurrent writes are tiebroken deterministically (P4 bound via tiebreak) | Low — simple merge (compare clocks, pick winner); no CRDT library; small payload | Concurrent writes are silently discarded (lost update); tiebreak by wall time = clock-skew oscillation (P4 violation); no vector clock = no concurrent-write detection = silent loss |
| LWW with wall-clock timestamp only | The data model is ephemeral (cache, presence); lost updates are acceptable; the clock is roughly synchronized (NTP) | Weak — convergence eventually, but concurrent writes may oscillate with clock skew; no concurrent-write detection | Lowest — one timestamp per write; no clock vector | Clock skew = oscillation (P4 violation); concurrent writes silently lost; not a correctness-safe strategy for durable state |
| Three-way merge (application-specific) | The data model is structured (documents, forms); conflicts should surface to the user or a domain-specific resolver; the merge is field-level | Bounded if the merge function is correct (associative, commutative, idempotent — P4, P5); conflicts surface per field | High — application-specific merge function per type; UI for conflict resolution; user-facing conflict surface | Merge-function bug = silent divergence or oscillation; unbounded conflict UI = user fatigue |
- The default for structured state that must converge silently is a
**CRDT** (state-based for unreliable networks, operation-based for
bandwidth-constrained reliable transport). The default for
arbitrary JSON where lost concurrent updates are acceptable is
**LWW with vector clocks** (never wall-clock-only for durable
state). The default for user-facing documents where conflicts
should surface is **three-way merge** with a documented resolution
policy.
- The failure-mode column is the P4 check: every row except
wall-clock-only LWW carries a bounded failure mode (the bug is in
the implementation, not the strategy). Wall-clock-only LWW carries
an unbounded failure mode (clock skew = oscillation) and is a P4
violation for durable state. Use it only for ephemeral state
where lost updates are acceptable.
- The choice is a P4 decision (which bound) and a C1 decision
(which correctness cost). A CRDT guarantees convergence but
constrains the data model; LWW is flexible but discards concurrent
writes. Neither is universally correct; the matrix is the
decision tool.
## Observability of Sync (P10 Edge Observability Survives Partition)
- Sync is itself an observable operation: the merge count, the
conflict count, the convergence lag (time from reconnect to
convergence), and the divergent-replica count are first-class
signals. A sync that runs forever without converging is the
`edge-sync-loop` chaos anti-pattern (P4 breach); without
observability it is invisible until the user notices the stale
state.
- A conflict that is silently resolved should be logged (the
resolution policy applied, the discarded write's idempotency key,
the winning write's clock). A conflict that surfaces to the user
should be metricated (the conflict rate, the resolution time).
See `domains/observability/metrics` for the generic discipline.
- A divergent replica that has not converged after the expected
window is an incident; without a metric it is invisible (P10
breach). Wire sync convergence to an alert — the
`divergent-replica-count` is the sync analog of the messaging
`consumer-lag` metric.
## What Violates Sync Discipline
| Violation | Principle |
|-----------|-----------|
| Sync loop that oscillates forever (CRDT without merge-semantics, LWW without monotonic clock) | P4 Sync Conflicts are Bounded, Not Infinite |
| LWW with wall-clock timestamp only on durable state (clock skew = oscillation) | P4, C1 (no concurrent-write detection) |
| Merge function that mutates inputs in place (retried merge is not idempotent) | P5 Edge Operations are Idempotent, `domains/concurrency/patterns` |
| Big-bang schema change under sync (replicas fail the merge) | P4, `domains/data/migrations` |
| Conflict silently resolved with no log (the policy is invisible) | P10 Edge Observability Survives Partition |
| Divergent replica with no convergence-lag metric (invisible stale state) | P10, `domains/observability/metrics` |
| Three-way merge with an unbounded conflict UI (user fatigue, no termination) | P4 (the merge must terminate) |
| Operation-based CRDT without causal delivery (lost ops = divergence) | P4, C1 (the delivery contract is the bound) |
| Merge that surfaces every concurrent conflict to the user (no default policy) | P4, C3 (the default policy is the simplicity bound) |
| Sync with no convergence test (the merge is untested under partition) | P4, `domains/testing/pyramid` |