1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/CONTRACT.md
T
karti b25f217e3e feat: real fire on the boards, the LA office as a twin, and a night sky worth reading
The world stops being a simulation of California and starts being California.

**THE PROMOTION GATE WAS THE FIRST COMMIT, BEFORE ANY ORANGE PIXEL EXISTED.**
On today's live store the SoCal board contains 22 incidents. Every one has NULL
acreage and fifteen are nameless LA County dispatch numbers. Drawn naively that
is 22 orange marks over Los Angeles on a day nothing is burning — in a frame that
contains no other warm colour, so one glyph would be the most salient object on
the board and twenty-two would spend its credibility permanently.

`acres >= 10 AND contained < 80 AND type != 'RX' AND last_seen = max(last_seen)`
returns 0 on SoCal, exactly 5 on California, 0 on the Bay — same body, same day,
three correct answers. The empty board is a deliverable, not a fallback: it says
"No active fire on this board — CAL FIRE and WFIGS, just now", states that 21
records were gated and why, lists the largest fires burning OUTSIDE the frame
with distances, and counts the hot pixels it is deliberately not drawing.

**The privacy leak is structurally impossible rather than carefully avoided.**
cloud-1 serves a projection; the four home-relative columns never leave that box.
`observations.threat` was the one that nearly got through — it is
`(16/distance)^2 x log10(acres) x momentum x containment x wind-alignment`, so
with acreage and containment public it inverts to a distance circle around a
house and three fires give an intersection. A grep of the built bundle for
distance_km, bearing_deg, threat, 7762 and the street name returns nothing.

**Deliberately not used, and both would have produced a confident wrong answer:**
the store's `air` table retains only the last parameter of each poll, so all 78
rows read "Good" while the live feed reports ozone 101 "Unhealthy for Sensitive
Groups" — haze driven off it would clear the sky during a smoke event. And
`weather` is written only inside the NWS alerts loop, so a quiet day stores no
wind at all. Tera's own per-region NWS wind is already correct and already what
the clouds drift on.

Satellite detections are drawn as evidence and never as incidents. The permanent
industrial heat source 4.7 km from the owner's house is flagged persistent and
dropped, asserted by a test that first proves it is present in the fixture.
MODIS integer confidence and VIIRS string confidence are branched on `sat`.

**The LA office is a twin.** Its entire authored second storey — Model Loft,
Model Bay, The Materials Room, 430 lines nobody had ever stood in — is reachable
on foot: a walker crosses level-1 to level-2 in 73 fixed steps, floorY 0 to 5,
verified against the real pack rather than a synthetic plan. Its two studio
devices read real hardware through a field-allowlisted bridge: mute, volume and
reachability only. Never level, because there is no passive level upstream and
obtaining one would record a room with people in it. Never dB, because upstream
is gainPct across four different native scales. The bridge refuses all writes.

Fixed at its root: an anonymous visitor was getting permanently at-rest
instruments backing off against a 401. The tier moves into `createDeviceSource`,
so anon gets the living simulator three file headers already promised.

**Item 8 is closed, not fixed, and the correction is the point.** The Bay Area
"stutter" was GPU power management — the card sat at 500 MHz of 2725 through
every run that reproduced it, 4096/2048/1024/256 shadow maps all render in
1.21-1.31 ms, and two consecutive runs over a byte-identical dist gave 33.4 then
16.7. The allowance is removed and the cell is back to 16.7. Geometry is the
gate; frame time is advisory.

Item 7 was re-scoped after measuring: 1,069,006 of the Bay Area's 2,265,056
triangles were the second submission of the same buildings into the shadow pass.
Mobile now has its own triangle caps and bay-area mobile draws 1,266,096.

Also: bridges and the freeway corridor light up at night as emission, not lights
— 1,614 deck lamps and 18 tower heads on the Bay in two draw calls. The single
change that made US-101 legible was moving its edge lines from the lit material
to the unlit one: retroreflective paint, the argument the SFO night frame already
makes. California went 21,991 lamps to 4,051, clustered at the 17 town districts,
because a rural interurban corridor genuinely is unlit.

Tests 1137 -> 1340, server 280. All ten budget cells pass on first attempt with
no cap raised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 18:01:11 -07:00

323 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# The contract
Five designs were produced in parallel and three critics tore into them. They
collided on fifteen blocking points — four files were specified twice with
incompatible contents, three separate backends were designed for one box, and
one type name was exported twice meaning different things.
This file is the resolution. **It wins over any individual design.** Anything
implemented here must match this document; where a design said otherwise, this
document is why it changed.
---
## 0. Scope
We are building **Lumbridge's own** Tera and office, plus a **dev kit** so
anyone can run their own on their own hardware.
Not in scope, deliberately: renting or purchasing parcels, billing, per-tenant
provisioning, cluster placement, a membership/tenancy system. Federation — a
self-hosted office announcing itself to a shared Tera — is a maybe-later that
the adapter boundary leaves room for and nothing designs toward now.
**The acceptance test for every decision below**, and the one that failed
end-to-end in the design round:
> A stranger clones the repo, runs one command, gets a city, copies the
> reference office pack, and has their own office — with no Lumbridge account,
> no Supabase project, and no keys.
Two CI jobs make that testable rather than aspirational, and they gate the repo:
`git clone && npm ci && npm run build`, and `docker compose up` under `env -i`
asserting `GET /api/v1/health` → 200.
---
## 1. Stage and scene lifecycle
Two designs both created `src/engine/stage.ts` with opposite lifecycles. The
retention argument wins on measured cost: the Bay Area's heightfield is 0.53M
lattice points and about 2.3 s to build — it was 336,864 and ~1.0 s when the
pack was San Francisco alone, and it grew, which is the point. Disposing the city
every time someone steps into an office and paying a second of rebuild on the
way out is not acceptable.
- **`Stage`** (`src/engine/stage.ts`) owns *only* the renderer, the RAF loop,
resize, and a swappable current scene. Nothing else.
- **`StageScene`** carries its own `THREE.Scene`, `PerspectiveCamera` and
`OrbitControls`. `stage.setScene(s)` swaps; the outgoing scene is **retained
and paused, never disposed**.
- Camera, controls, lights and picking live in a per-scene **`SceneKit`**
helper that both `createScene` (city) and `createOfficeScene` construct.
They do *not* live on Stage.
Rationale for the split: the city and an office cannot share a `THREE.Scene` at
all. SF's `latScale` puts one scene unit at ~94 m with 3.6× vertical
exaggeration; an office renders at 1 unit = 1 m. Two scenes, one renderer.
```ts
export interface StageScene {
scene: THREE.Scene
camera: THREE.PerspectiveCamera
controls: OrbitControls
onEnter?(): void
onExit?(): void
tick(dt: number, elapsed: number): void
dispose(): void
}
export interface Stage {
renderer: THREE.WebGLRenderer
setScene(s: StageScene): void
current(): StageScene | null
dispose(): void
}
```
## 2. The office data contract — one file, one type
`src/interiors/plan.ts` was specified twice with incompatible contents, and a
fourth design imported an `OfficeDoc` no design produced.
- **`src/interiors/types.ts` is the single authored contract.** It exports
`Office`, and must stay JSON-serialisable — no functions, no classes, no
THREE types — because a hand-written pack and one arriving over HTTP have to
be the same thing.
- Walls are an **explicit segment list with 1-D `openings`**. Room polygons
imply no walls; they are floor slabs. This wins because the opening-splitting
pass produces the walk-mode collision segments for free, and because
auto-generating walls from shared room edges needs float-equality dedup.
- **Doors and windows are `Opening` records punched out of a wall**, not
placeable assets. `shell.wall.ts`, `shell.door.ts` and `shell.window.ts` are
deleted from the asset library; `shell.ts` calls a parameterised `wallRun`
part per solid run. Shipping both would put every opening in the scene twice,
or leave the collider with no gaps.
- **`src/interiors/plan.ts` is the `Plan` class** — the `World` analogue.
Resolves an `Office` once into wall runs, seats, props, collision segments and
bounds. The other design's `Placement[]` is **`Plan`'s output**, an internal
build input, not a second authored format.
- `OfficeDoc = { id, name, floor: Office, … }` lives in `src/server/wire.ts`,
not in interiors.
## 3. Assets
- **Procedural TypeScript only.** Every mesh is a function composing cached unit
primitives; every texture is drawn on a 2D canvas from seeded noise. No binary
art is ever committed. This is the same property that gives the city zero
asset-licensing exposure, and it is worth more than the quality ceiling it
costs.
- The **assets registry wins** over the interiors one — it is strictly richer
(roles, quality, overrides, footprint-without-build). `src/assets/kit.ts`
defines `AssetDef` and `AssetId`; `src/assets/materials.ts` defines
`MaterialRegistry` keyed on a closed `SurfaceRole` union.
- **`Prop.kind` is an `AssetId`.** The prop registry and the asset registry are
the same registry.
- Namespace is **`tera:`** (`tera:desk.workstation`), not `lse:`. A self-hoster
registers `acme:desk.standing` with `overrides: "tera:desk.workstation"` and
reskins without forking.
- Assets are authored in metres, 1 unit = 1 m.
- `ghostOf()` moves onto the assets `MaterialRegistry` for the wall-occlusion
fade.
### 3.1 The art licence lands with the first asset file
Apache 2.0 covers the code. The **artistic output is additionally dedicated
under CC0-1.0**, so a mesh can leave this repo without dragging a NOTICE
obligation into someone else's project.
This is decided now, not later, and `src/assets/LICENSE-ART` plus the
`CONTRIBUTING.md` inbound terms land **in the same commit as the first file
under `src/assets/`**. Deferring it is the expensive mistake: eighteen asset
builders and a ~180-prop reference office is a lot of authored work to
accumulate before anyone states the terms, and relicensing art once
contributors exist is close to impossible.
The inbound grant must be **standing, not per-PR**. Apache 2.0 §5 supplies a
default inbound=outbound grant for Apache-2.0 only; there is no default inbound
CC0, so a single merged PR whose author never said the words leaves that
contribution Apache-only and makes a directory-wide claim false. `CONTRIBUTING.md`
therefore carries a DCO-style sentence — *by submitting a change under
`src/assets/`, you license it under Apache-2.0 and dedicate the artistic output
under CC0-1.0* — so submission itself is the grant. `LICENSE-ART` is worded as a
dedication made by the copyright holders of the material, not as a property of
the directory. NOTICE gains the carve-out, because NOTICE is what a downstream
consumer actually reads to learn the repo is not uniformly Apache-2.0.
## 4. Lighting and environment — one owner, one direction
`Environment` was exported twice meaning different things, and two modules both
constructed and mutated the same three lights.
- **`Environment` is an observation**: `{ time, sun: SolarPosition, weather }`.
- The lighting *state* is renamed **`LightingState`**.
- **`Atmosphere` is the sole light owner.** `atmosphere.apply(env) →
LightingState`, which the scene applies. One direction, no write-backs.
- Solar position is computed locally with **no network** — a NOAA/Meeus
implementation in `src/engine/solar.ts`, dependency-free.
- An **office with no `site` gets no Atmosphere**: `fog: null`, no
`scene.background` drive, interior lighting is its own fixed rig. This is
still the default and still the promise — a pack can be authored, rendered and
shared without owning a coordinate, an account or a network.
- **An office that declares a `site` gets the same sun the city does.** This is
the "later refinement" this clause reserved, taken up rather than a reversal of
it: `Atmosphere` is still the sole light owner and there is still one direction
of flow. `interiors/daylight.ts` adapts what `apply()` returned; it computes no
light of its own.
Two things are true only indoors, and they are the whole of the adapter:
- **The building is rotated.** `Atmosphere` works in the city's frame, where
Z is north because a city pack is a map. `OfficeSite.heading` is the bearing
the pack's Z actually points along, and the sun is turned by it — otherwise
"the daylight side" in a pack's comments is a label rather than a fact.
- **The fog starts outside.** A city fog beginning 1,150 units away is fine at
94 m per unit and is *inside the room* at 1 m per unit. The colour is kept
and the distances are replaced.
A sited office also gets a `sky` and a ground plane at `-site.elevation`, which
is what makes 188 m up a tower feel different from 4 m above an airfield.
**A third input joined the adapter, and it is still not a second opinion about
the light.** `officeDaylight(state, site, smokeLoad)` takes a 01 scalar for
how much wildfire smoke is in this building's air, and moves the haze colour,
the haze near-distance and the sun's tint — three numbers the adapter already
computed. It is clamped at the boundary, bit-identical to today at zero, and it
changes nothing about who owns the rig: `Atmosphere` still decides the sun and
the sky, `daylight.ts` still only adapts what `apply()` returned, and the
scalar is handed *in* from `main.ts` rather than fetched. A room may not reach
into the fire layer and decide its own sky; that would be the second sun this
clause exists to prevent, arriving through a side door.
The fire layer itself constructs no light of any kind. A burning hillside at
night is emissive material plus one additive ground quad in the same instanced
mesh — the mechanism `nightlights.ts` uses for San Francisco's 12,038 street
lamps — because a `PointLight` per fire is exactly the case this clause forbids
and exactly the case that tempts one.
## 5. One server
Three backends were designed for one box — three ports, three frameworks, three
`deploy/Caddyfile.snippet` files that would overwrite each other.
- **One Fastify workspace**, `server/`, listening on `127.0.0.1:8431`, serving
`/api/v1/*`. One systemd unit, one Caddy snippet.
- Weather and office routes fold in as route modules, not services.
- Env prefix is **`TERA_*`** throughout.
- The Workie sync oneshot stays the **only** second process — it is the sole
holder of a Workie credential, and that isolation earns itself.
- Private per-user markers are **never proxied**. The authenticated browser
calls Workie directly with its own token, so private rows never transit the
public box.
- `Cache-Control` is fail-closed: a global hook stamps `private, no-store`, and
a route opts in to public caching explicitly.
- Authenticated realtime is a route module in this same process, not another
daemon. Its state is bounded and memory-only. Event credentials travel in a
POST body, never a URL; the server owns interest filtering, motion validation,
token rotation and revocation. The auth subject is not a peer-visible entity
id, and no media is carried on the game-state stream.
### 5.1 Zero-config boot must actually boot
Both server designs made the same independent mistake: a weather source
defaulting to a provider that requires a contact string, with a hard failure
when it is absent — which fails the very acceptance test they named.
- `TERA_WEATHER_SOURCE` defaults to **`none`**.
- A source set without a contact is a **demotion, not a fatality**: log one loud
line and serve the `synthetic: true` clear-day body.
- The `env -i` CI job is what keeps this honest.
- **`TERA_FIRES_SOURCE` defaults to `none`, and `none` invents nothing.** The
asymmetry with the weather is deliberate and is the sharpest line this round
drew: an invented clear day is a defensible synthetic default, and an invented
wildfire is a claim that a named place is burning, made to somebody who may
live there. So `none` serves a real, empty body, and the board says how old its
last answer is rather than showing an all-clear it cannot support.
- Off-by-default is a **choice**, not a misconfiguration, so an unset
`TERA_FIRES_SOURCE` appends nothing to `degraded[]` — which is one sentence per
*demotion*. `scripts/check-zero-config-boot.mjs` refuses to pass with any
demotion at all on an empty environment, and that is the gate that keeps the
distinction real: a stranger's clone is not a broken deployment.
### 5.2 Weather sources
`api.weather.gov` (NWS) is US-government public domain, keyless, and the default
*once a contact is configured*. `met.no` is the global fallback. Open-Meteo is
opt-in and off by default: its data is CC-BY 4.0 but its free tier is
non-commercial, which is the wrong default for a product page.
## 6. Auth
Scope-corrected: no membership tables, no tenancy.
- **`TERA_AUTH_MODE` defaults to `none`.** A self-hoster gets an open office and
never creates an account anywhere.
- Lumbridge's own office uses `sso` mode, reusing the pattern already running on
the fleet: the world holds **no credentials**, is handed an entry URL and a
**server-side revalidate URL**, and enforcement happens on the server. Both
are env vars, which is exactly what a dev kit needs.
- Where a JWT is verified directly, **HS256 against a shared secret is primary**;
JWKS sits behind an env switch. This is a correction from verified fact — the
fleet's Supabase issues `{"alg":"HS256"}`, so a JWKS-only implementation would
reject every real token.
- A private office returns **404, not 403**, so the endpoint cannot be used to
enumerate what exists.
- **A refused feed must produce a working instrument, not a dead one.** The
studio device route is members-only and answers 401 to an anonymous GET, which
is correct — the microphones and the camera behind it are hardware in
somebody's room. What is *not* correct is asking anyway. `Capabilities` now
carries `liveDevices` (`tier !== "anon"`) alongside `Feeds.devices`, and both
must be true before the API strategy is chosen; when either is false the
bundled fixed-step simulator runs in the tab instead. Passing only the
deployment's half is what shipped a permanently at-rest instrument panel to
every anonymous visitor on cloud-2, backing off exponentially against a
request that could never pass. This is the same anon-first rule
`SimulatedFlights` and `sample.ts` already follow, applied to the one feed
that had a real refusal behind it.
- `@supabase/supabase-js` is a **real dependency**, and the "no surprise
dependencies" CI check becomes an **allowlist naming why each is permitted**,
not a count. A dynamic import of an uninstalled package fails the Vite build,
which would have made the `auth: none` default — the committed default —
unbuildable.
## 7. The binary gate must not fire on the self-hoster
The no-binary-art check is a promise about **this repo's committed art**, but as
designed it walked the working tree and would fail a self-hoster's build on
their own legally-clean `.glb` — while two other designs told them to put files
exactly there.
- Enumerate via **`git ls-files`**, never a filesystem walk, so untracked local
assets are invisible to it.
- Strict over **`src/**`** — that is where the licensing argument lives.
- Hard-exempt `public/props/`, `public/kits/`, `public/offices/`, `docs/`, and
add them to `.gitignore` marked as self-hoster space.
## 8. Geocoding — the correction that matters most
`ARCHITECTURE.md` §3.2 argued that keeping geocoded company coordinates out of
the repo solved the ODbL problem. **That reasoning is wrong**, and this is the
sharpest thing the critics found.
Containment solves licence *mixing inside the repo*. It does not touch ODbL's
actual trigger. Serving a snapshot of OSM-derived coordinates at a public
endpoint is **Publicly Using a Derivative Database**, which brings ODbL §4.3
attribution and §4.4 share-alike onto the served data regardless of where the
rows are stored. Storing them off-repo hides the obligation; it does not
discharge it.
Nothing is committed to yet — Workie has no geocoding code today — so:
- The geocoder is the **US Census Geocoder** (`geocoding.geo.census.gov`): a US
Government work in the public domain, keyless, and covering SF, LA and NYC,
which is every city planned.
- **Google, Mapbox and HERE do not solve this either** — their terms restrict
storing and redistributing returned coordinates, which is precisely what a
public snapshot does.
- The sync script records a **per-row provenance field**, and the public-shape
assertion **rejects any row whose provenance is not on a non-ODbL allowlist**.
The gate that already checks field names now also checks where a coordinate
came from.
- `NOTICE`'s GEOGRAPHIC DATA block gains this next to the existing USGS/SRTM
sentence, and `ARCHITECTURE.md` §3.2's reasoning is corrected rather than
quietly left standing.