Open equipment data for off-grid systems, with sourced specifications, protocol dialects, and downloadable datasets.
The dataset covers charge controllers, inverters, batteries, BMS, shunts, generators, and meters, with a source and confidence on each fact. Browse it at data.origin89.com, or download it as Parquet, CSV, and JSON.
Licence: MIT, for the tooling, the records and the built artefacts alike (LICENSE). Use it for anything, keep the notice. Manufacturer documents in the archive are their owners' and are not covered; a source record says whether one may be redistributed, and the default is that it may not. Bulk feeds keep their own notices; SAM's libraries are BSD-3-Clause.
The dataset joins equipment models, rated figures, protocol dialects,
manufacturers, and source documents. Authored records and imported feeds keep
their provenance and review state. The published index
reports current row counts and file hashes; src/tables.ts defines the exports.
A dialect is a register map or frame layout. A model belongs to one only when a source supports matching addresses. Missing values stay absent, and imported or extracted claims do not become human-reviewed evidence automatically.
records/
families/<family>.json the prose around a family's entries, and their order
dialects/<family>/<id>.json one dialect: driver, confidence, sources, models, gotchas
sources/<id>.json one source: url or path; title, hash and licence once reviewed
packages/schema/src/ shared Zod schemas; the enums are the closed vocabularies
src/ validate, flatten to tables, build the release
tools/catalogue/ parse and render the catalogue's markdown form
apps/site/ React site
apps/worker/ Cloudflare crawler and public data endpoints
dist/ built, never committed
A record changes in a pull request with a source attached, or it does not change. Git is the review tool.
just lists everything, with a line each saying what it is for.
just check # what CI runs: both test suites, validation, two builds compared
just gate # what is waiting for a person, most in-scope first
just is ep-solar epever "XTRA2210N matches the dialect the catalogue documents"
just sync-sam # is the pinned dataset still what upstream publishes?
just dev # the Worker locally, with a local R2
just discover rolls-battery rollsbattery.com 2026-09-09
just plan rolls-battery 2026-09-09 # read it before approving it
just approve rolls-battery 2026-09-09 40 # recorded as whoever `just login` signed in
just convert rolls-battery 2026-09-09 # each document then enqueues its own reading
just specs rolls-battery 2026-09-09
just specs-ready # every maker whose run is converted and read; a daily job opens this as a PRSet OFFGRID_BASE_URL=https://data.origin89.com and run just login once, and
the same recipes drive the deployed spider; without it they talk to just dev
on this machine. just login signs in with GitHub, and only members of the
origin89hq/working-group team get in. The sign-in lasts eight hours.
The catalogue migration keeps its own commands, since it runs once:
pnpm roundtrip:catalogue <catalogue dir> # parse and render every family file; must print "same" for each
pnpm import:catalogue <catalogue dir> # rewrite records/ from the cataloguebuild needs the duckdb CLI for the Parquet files and refuses to run with a
validation error. Two builds of the same records produce identical bytes; the
test suite checks that, because a Parquet writer is not deterministic by
promise.
records/models/ is what everything else joins to: one record per product a
manufacturer makes, keyed by maker so two companies can both have an "X-1".
Models are derived from the crawls — a listing contributes only when its brand
string has been resolved at the gate, because a model with no owner is a
string — and a derived model carries no reviewer until somebody checks it
against the maker's own document.
pnpm models 2026-09-11 solacity thecabindepot --dry-run # see what a crawl would yield
pnpm models 2026-09-11 solacity thecabindepot # write the recordsThe filter is deliberately strict, because a seller's model field is often just the title again. "Estate Lawn Seed 25 lbs" arrived in this repo as a model number, which is the case the rule is written against: a name wrongly rejected is a row somebody can add, while a sentence wrongly accepted is a product that does not exist and that a later reader has to disprove.
A model's kind is absent until something classifies it. That is a real
answer, not a gap to fill with a default: 5,889 of the current models came from
crawls no classifier had run over, and calling them all balance-of-system would
have been a guess wearing the shape of a fact.
Hop two ends in figures. Once a manufacturer's documents are archived, two more Workflows finish the job:
curl -X POST 'localhost:8787/convert?id=rolls-battery&date=<date>' # PDFs to markdown
curl -X POST 'localhost:8787/extract?id=rolls-battery&date=<date>' # markdown to readings
pnpm specs:pull rolls-battery <date> --dry-run # see what would land
pnpm specs:pull rolls-battery <date> # write the figuresA reading is addressed by the document's bytes and the reader, so a document
read once is never read again, whichever run asks: that is what keeps a second
run from paying twice, and also what keeps a changed prompt from reaching a
document already read. A new reader model is a new reader: after a change of
model every document is unread by it, and just convert reads it again without
just forget first. just forget <maker> counts the prompted readers'
readings and windows for the maker's approved documents, and
just forget <maker> false removes them, two hundred documents a call until
the maker is done; the next just convert then reads those documents again,
which spends reader credits, and the page reader's offer marker goes too so the
scanned ones are offered again. The converted markdown,
the transcribed pages and the table parser's reading are kept, since none of
them depends on a prompt.
A figure is attached only when the document's own name for the product answers to a model held for that maker. A product the maker names and no shop we crawled sells becomes a model first, because a datasheet naming "S48-300LFP STACK-LV" is better evidence that the product exists than a listing is. Nothing extracted is confirmed: a spec row names the model that read it, and validation refuses one that is neither extracted nor reviewed.
Two things the first real run taught. Rolls' lithium datasheet converts to 1,497 characters of scrambled chart labels, because its ratings are pictures and not text — a maker's catalogue is not uniformly readable and the conversion index records which documents were. And the figures came out of a UL test report instead, which is where the ratings for that range are actually tabulated.
records/specs/ holds the ratings, one row per figure rather than a column per
field. A battery and an inverter share almost no columns, and a single
capacity_ah would have to pick one discharge rate and lie — the Rolls S-550
is 428 Ah at the 20-hour rate and 556 Ah at the 100-hour rate, which is two
rows each carrying its own conditions. Every figure names its source, the page
it was read from, and how much weight it carries. The table is empty: filling
it is the next piece of work, and the numbers come from the archived
datasheets rather than from a product title.
Some products come from a dataset that already states its figures with their
units, and reading those is worth more per row than anything taken out of a PDF.
feeds/ holds each one redistributed with its licence and pinned by hash; a
changed file fails the pin rather than quietly becoming a different dataset.
Twenty-five thousand rows are not twenty-five thousand records. What is reviewed
is the adapter and the pin, and the build regenerates the rows every time, so
they never enter records/ and never need a reviewer.
| Feed | Licence | Products | Figures |
|---|---|---|---|
| SAM component libraries, CEC modules and inverters | BSD-3-Clause | 24,020 | 292,546 |
just sync-sam checks the pin against what upstream publishes now. A changed
file is reported — how many products arrived, left or moved — and the pin only
moves with just sync-sam-accept. A feed that updated itself would mean the
figures published here could change without anybody having looked.
Each pinned file is a row in sources, at its commit and with its hash, and a
feed figure cites that file and nothing the file does not contain. Its unit is
the one the library's units row states; that row leaves STC and PTC power
blank, so those figures publish without a unit and doubted, and a consumer that
wants them in watts takes that from SAM's documentation, as the property layer
will. A module's STC figures carry conditions: STC, the library's own label
for standard test conditions (PTC is PVUSA test conditions), and its
temperature coefficients are kept in A/K, V/K and %/K. A name the library lists
twice keeps both rows, the second under a -2 suffix, so every id is unique. A
figure's id is its model and its name, so adding a column moves no other id.
Every model and every figure carries a tier that says where it comes from:
record for a record in this repository, feed for a row a public dataset
states. A reader that cannot tell them apart will quote the wrong one, so it is
a column and never implied.
The tier says nothing about checking. Filter on reviewed_by for what a person
confirmed against the document. extracted_by names what read the rest: ai:
is a model reading prose, usable and unconfirmed, and table: is a parser
reading the maker's own specification table. A record merged through a pull
request is not thereby reviewed, and a feed row has neither column. A feed
attaches to a manufacturer only when its printed name answers to one somebody
confirmed — 762 rows do — and an unknown name stays a name, because minting
makers is the gate's decision.
| Table | One row per |
|---|---|
dialects |
dialect: family, driver status, confidence, whether a refuter checked it, transport and blocks as prose, the review verdicts |
dialect_sources |
citation, in the order the entry lists them |
dialect_models |
model named under a dialect, with tier, rating, seller and notes where the catalogue had them |
dialect_kinds |
metric a dialect reports or command it accepts |
dialect_gotchas |
thing that bites, in order |
dialect_readings |
one reading, structured: metric, register or field, unit and scale, sign, width, whether the device measures or only reports it, and the document it was read from |
dialect_codes |
one entry of a vendor's fault, alarm, charge-stage or state table, with its source |
dialect_see_also |
sibling id an entry may duplicate |
models |
product a maker makes, with its kind where something has said, a battery's chemistry with what established it, where its maker states it, and the aliases other names reach it by |
model_keys |
name a model answers to, under each name its maker goes by, as the key the one rule gives it |
model_dialects |
dialect a model is known to speak, with how the link was made (a register match, the maker's document, or only the catalogue naming it), what the sources support, and a firmware range where one is stated |
model_dialect_sources |
citation behind a model–dialect link, in order |
specs |
one rated figure, with its unit, the conditions it holds under, its source and page |
properties |
one figure read under a registry key: a number in the key's unit, its conditions as columns, the figure it came from, the rule or column that read it, and its basis |
property_gaps |
key a model's figures could not fill, with the reason |
property_coverage |
key and kind: how many models the key applies to, how many have a value, and the gaps by reason |
sources |
source: url or repository path, plus title, publisher, revision, hash, retrieval date and licence once reviewed |
model_keys is how a name is resolved to a model, here and in the API: a key is
the maker's or brand's name followed by the model name, each in Unicode NFKC
form, lower case, with spaces and dashes removed and the maker's own name taken
off the front of the model name. The rule lives in packages/api so a consumer
can reproduce it. A key that reaches two models is ambiguous, and stays so.
Absence is an empty cell, never a default. confidence is one of vendor-doc,
community-crosschecked, community-single, unverified; the last must not be
built on. refuter says whether a second pass tried to knock the entry down.
shared_map_claim_dropped is true where the refuter found the models were
grouped because an agent proposed it, not because a source showed matching
addresses.
A figure is published as printed, which preserves the claim and means nothing
can compare, filter or calculate across makers: a controller's PV open-circuit
limit appears under 53 names, "Max. input voltage" among them, and some are
not numbers. properties is the same figures read under one key each, from
the registry in packages/schema/src/properties.ts and published as
properties.json: pv.voc.max is a voltage, in V, one number, for anything
with a PV input, and it limits the pv-voltage reading.
Nothing is guessed. A figure reaches a key only through a mapping rule,
reviewed in a pull request. records/mappings/shared.json holds the names
that say in full what they measure wherever they are printed, "Maximum PV
open circuit voltage" or "Rated output power", and applies to every maker. A
maker's own record under records/mappings/<manufacturer>.json holds its own
wording, scoped to that maker and, where the wording is one document's, to
that document; "Max. input voltage" is the open-circuit limit on Victron's
sheets and may not be elsewhere. A maker's rule comes before the shared one,
and a maker lists under except any shared name that means something else on
its sheets. The SAM feed maps by column, in code. The value is read by
src/quantities.ts, which takes "150 volts DC", "8 - 72 Volts dc",
"12/24/48V DC" and "-0,29 %/°C" and refuses a bound, a sentence, a number
with no unit, or a unit outside the key's quantity. Conditions are
structured: a capacity at C20 and at C100 are two rows, and a surge without
its duration is not a surge. A lithium pack's capacity stands without a rate,
so battery.capacity waives it for a model whose chemistry is lifepo4 or
lithium; a lead-acid pack's, or one whose chemistry the record does not
state, still needs it. A continuous or surge figure a sheet prints in
VA is read under the watt key's apparent-power sibling, inverter.power.apparent
or inverter.power.apparent.surge, through the same rule.
Chargers, generators and pumps have keys of their own: charge.power.max and
charge.battery.capacity for a mains or DC charger, generator.power.running,
generator.power.starting and generator.fuel.tank for a generator, whose
fuel condition is read off the name where a dual-fuel set rates LPG and
natural gas apart, and pump.flow.rated, pump.head.max, pump.pressure.max
and pump.power.rated for a pump, in litres a minute, metres, bar and watts,
with gallons, feet, psi and horsepower converted. A sheet that prints two
figures in one cell, "Watts (Starting/Running)" = "5500/4000", is read by two
rules, each naming the part it takes.
Validation audits the mappings against what the build read: a figure a rule
names under a key the model's kind has, that no key read, is an error, and the
report lists what a mapping's author should look at, an unmapped name one word
from a mapped one, figures a rule reads without a unit, a temperature or a
time the key drops, a model filed under a kind its figures contradict.
test/fixtures/property-coverage.json is what the mappings read today, per
maker and per key; a change that moves it has to run just coverage-snapshot
and commit the diff, so a pull request carries its effect on coverage. A new
maker's file starts from just mapping-draft <manufacturer>, which lists what
the maker prints that no rule reads, by the key each name most likely means.
What cannot be filled is a gap with a reason in property_gaps: no-claim
when no figure was read for the key, unparsed when the figures could not be
read as numbers, needs-conditions when a value lacks a condition the key
needs or its rule requires, conflict when two usable values under the same
conditions disagree, in which case both publish with status = 'conflict'. A
figure that could not be read beside ones that could is still a gap. basis
is the figure's, never the rule's: reviewed when a person confirmed it,
extracted when only a reader took it from the document, feed for a public
dataset's row. property_coverage says, per key and kind, how far this
reaches in each release: the models with a value, those of them that are
partial, and the gaps by reason.
tools/catalogue parses the eight family files into records and renders them
back. The render is byte-identical to the source for all eight, which is the
proof that the records hold everything the prose held. Once the catalogue is
regenerated from these records instead of edited by hand, the render becomes
the generator and the parser becomes a migration tool to delete.
pnpm validate ends with a count of review items. After the first import:
- every source lacks a title and a licence decision;
- 51 sources are cited by title alone and have no url or path;
- 164 dialects are
unverified, 122 were never refuted, 98 were refuted, and 75 lost their model grouping on review; - 16 dialects are flagged as a possible duplicate of a sibling id.
None of these block the build. They are the work.
apps/worker/ contains the crawler, archive API, and site server described in
docs/SPIDER.md. The committed seller list selects the shops it
can crawl. Manufacturer discovery lists documents for a person to approve
before download. Classification, conversion, and document reading run through
bounded queue jobs; failed jobs remain visible in the dead-letter queue.
Each crawl has a run ID. Archive readers follow the current pointer and keep sightings and classifier results within that run. A sighting records what a seller printed; resolving its brand to a manufacturer remains a review decision.
Run just test for local tests and just dev to start the Worker. Copy
apps/worker/.dev.vars.example to apps/worker/.dev.vars and set a local control
token first. The local AI binding calls Cloudflare, so crawler commands can
spend money. Use just --list for the available operations; download approval
and deployment are separate commands.
apps/worker/scripts/gate-report.ts prints what a person needs to resolve brand
strings into manufacturers: every brand a seller printed, how many listings
carry it, what kinds the classifier thinks they are, and the model numbers it
read off the titles. It uses the same archive reader as the queue and model commands.
Run just dev for local reads, or set OFFGRID_BASE_URL and run just login
for a deployment. The supplied date must match the current crawl; a mismatch or
missing archive part stops the report.
cd apps/worker
pnpm gate solacity 2026-09-09 # brands with at least one in-scope listing
pnpm gate solacity 2026-09-09 --all # including the ones that look out of scopeA guess is never a fact. It lives beside the sightings under the classifier's id, so a second model or a changed prompt writes a second set and the two can be compared before either is trusted.
The spider deploys from CI, not from a laptop, and the deploy is manual: it arms
a weekly cron that crawls three dozen shops and can spend money on a model, so
it is a decision somebody makes rather than something a merge does. Run
Deploy the spider from the Actions tab on main.
It needs, once:
| Where | Name | What |
|---|---|---|
| Repository variable | CLOUDFLARE_ACCOUNT_ID |
the account the Worker and bucket live in |
| Repository secret | CLOUDFLARE_API_TOKEN |
Workers Scripts edit, Workers R2 Storage edit, Workers AI read |
| Environment secret | GH_APP_CLIENT_SECRET |
a client secret of the Origin89 Data GitHub App; the Worker's GITHUB_CLIENT_SECRET |
| Environment | offgrid-equipment-production |
where the approval reviewers live, if you want a second pair of eyes on a deploy |
| GitHub App | Origin89 Data | owned by and installed on origin89hq; Members read, device flow on, callbacks https://data.origin89.com/auth/callback and http://localhost:8790/auth/callback |
| Team | origin89hq/working-group |
who may use the control routes |
The Worker's config names its secret under secrets.required, so wrangler
generates its binding type and warns in local development when it is missing.
There is no hand-written Env to drift from what is actually deployed. The
secret goes up with the version, so a first deploy is not circular.
No workflow holds a long-lived credential for the Worker; the deploy's Cloudflare token is for Cloudflare's API. The Worker takes a control token only on a request addressed to this machine, so one left on the deployed Worker opens nothing, and the deploy deletes it anyway, since a secret stays on a Worker until it is removed.
Every endpoint that starts a crawl, spends money or releases a download needs a
member of origin89hq/working-group. A terminal sends the token just login
stored; a browser signs in at /auth/login and carries a cookie. The Worker
checks with GitHub that the token was issued to this app and that its owner is
an active member of the team, and remembers the answer for five minutes, so
removing somebody from the team locks them out within five minutes. A token
somebody gave another app, gh's included, is refused. An approval records the
login GitHub vouched for.
Members manage collection at https://data.origin89.com/ops. The workspace
shows the supervisor's latest pass and the current manufacturer and seller runs,
with searchable queues, workflow status, archive files, and CSV exports. Run
snapshots refresh on demand; a failed refresh keeps the last successful snapshot
and its timestamp visible. Anybody else is sent to sign in.
Open a manufacturer run to review its document plan and approve a bounded number of downloads. The approval targets the reviewed workflow instance and records the signed-in member. Collection controls start a fresh manufacturer discovery or seller run after checking that the current workflow is no longer active. Existing archive files remain available. If a submission's response is lost, inspect a fresh snapshot before trying again; the workspace never repeats it automatically.
The records workspace queries the published dataset and opens authored JSON files
from the repository for correction. It validates drafts against the shared record
schemas and exports a patch for repository review. IDs and dialect families stay
fixed; reference and evidence checks still run through just check. Generated
feed records need a change in their source feed. The published-files view provides
downloads, row counts, sizes, and content hashes.
Only just dev has a control token, from apps/worker/.dev.vars, and a Worker
with neither a control token nor sign-in configured refuses everything rather
than allowing everything. To try sign-in locally, add GITHUB_CLIENT_SECRET to
.dev.vars and use Chrome or Firefox, which accept a secure cookie from
http://localhost.
A workflow that calls the Worker holds no secret for it. The job asks GitHub
for an OIDC token with the audience https://data.origin89.com, and the Worker
takes it only from a job on main in this repository, checked by repository and
owner ID, and only from the workflow each route names:
| Workflow | Routes | Started by | Environment |
|---|---|---|---|
publish.yml |
PUT /v1/:file |
daily, by hand, or after a successful Worker deployment | offgrid-equipment-production |
pull-figures.yml |
/state, /archive, /readings |
its schedule, or by hand | any |
supervise.yml |
/supervise, /state, /vision |
by hand | any |
A pull request never gets in: it runs a workflow as its own branch has it. The
tools ask for a job token themselves when they run in a job with
id-token: write, and ask again for each request, since a token lasts minutes.
Publish the dataset runs on every push to main that changes what the
tables are built from. Each file declares its sha256, and R2 refuses a body
that does not match it. The manifest goes last, and the Worker refuses it
unless every file it names is stored at the size and digest it states.
just publish --dry-run lists what would go up; without --dry-run the
command works only inside that job.
Between the two hops sits the one step a model does not get to take: deciding which company stands behind a brand string a seller printed. Nothing crawls a manufacturer's site until that is answered, so a reseller's own label and a rebadged generic cannot quietly become a maker.
pnpm gate:queue 2026-09-09 solacity thecabindepot # fold crawls into the queue
pnpm gate list # what is waiting, most in-scope first
pnpm gate show ep-solar # one entry with its evidence
pnpm gate maker epever "EPEver" https://www.epever.com epever.com
pnpm gate is ep-solar epever # this brand is made by that company
pnpm gate skip lodge "cookware" # not equipment this database coversqueue never decides anything. A brand new to it arrives unresolved and a
brand already answered keeps its answer and takes the fresh evidence, so the
queue is a backlog under version control rather than a list that regrows every
week.
Every decision names who made it and what settled it, and validation refuses one that carries neither. A reviewer may be a person or an agent working the queue; what is refused is the bulk classifier naming itself, because a guess over a product title is evidence and was never a decision.
Of the first 190 brand strings, 97 resolve to a manufacturer, 85 are out of scope, and 8 are left unresolved because nobody could name their maker — which is a real answer and is why the queue has three states rather than two.
records/manufacturers/ holds the companies. A manufacturer's domains are
what hop two is allowed to crawl, so a reseller's domain does not go in one.
Its documentHosts are where its own pages keep their PDFs when that is a
shop's CDN or a CloudFront distribution: a document there is offered only when
a page on the maker's domains links it, and no page there is read. An empty
plan names the hosts a maker's pages linked, which is how you know what to add.
Hop two reads a maker's sitemap for document links, writes the plan, and stops.
curl -X POST 'localhost:8787/maker?id=victron-energy&domains=victronenergy.com&pages=40'
wrangler r2 object get offgrid-equipment-archive/documents/victron-energy/<date>/plan.json --local --pipe
curl -X POST 'localhost:8787/approve?id=maker-victron-energy-<date>' -H "authorization: Bearer $CONTROL_TOKEN" -H 'content-type: application/json' -d '{"approved":true,"limit":50}'Nothing is fetched until that event arrives. A refusal, an approval naming no
host that was found, and no answer at all all end the run having downloaded
nothing. An approval can narrow what discovery found and can never widen it,
and it names who gave it: the GitHub login the Worker verified, never a name
the request typed. A limit takes the documents the records already cite
first, then the rest in the plan's order, which lists them the same way.
The workspace's Activity feed records collection starts and outcomes, download approval decisions, supervisor summaries, and dataset publications. Filters apply on the server; a sparse page can still have older events to load. Run links name the archived attempt rather than whichever run is current today. Activity writes retry independently and log failures without repeating collection work. This is operational history, not a complete audit log: interrupted or externally terminated workflows and exhausted history writes can leave gaps.
Releases & changes records each publication, including rollbacks to earlier content, with its verified source commit and job attempt. A content hash identifies identical datasets; retrying the same job attempt repairs the same publication. Choose two versions and a record type to inspect additions, removals, changed field paths, before/after records, and file changes. Record counts refer to authored records; CSV tables can have more rows because they expand nested claims. Publishing identical content in another job or rerun records a new occurrence.
Beside each table's CSV and Parquet the build writes its rows as newline-delimited
JSON in parts of at most 20,000 rows, models_0001.ndjson and so on, and the
manifest's load section says which parts make each table. The publish step keeps
every part content-addressed as well, so a loader reading a release by its manifest
reads the bytes that manifest named, whatever was published since (#83).
The build also writes a snapshot of each authored record type for comparing
versions: its records in id order as JSON arrays, records_specs_0001.json and so
on, each part at most 2 MiB and 10,000 records, and the manifest's snapshots
section says which parts hold each type. Publication keeps every part by SHA-256
in R2 and checks the plan against the stored parts before accepting the manifest.
A comparison walks both versions' parts in id order, holding one part of each in
memory, and reads at most 64 parts a type. Versions published before snapshots
had parts keep one records_<kind>.json of at most 6 MiB and 50,000 records, and
still compare. Missing, oversized, malformed or out-of-order history is reported
explicitly. History reads require the existing member session. History itself
needs no binding beyond R2.
An accepted manifest also starts a release-load Workflow, which loads the
release's tables from their content-addressed parts into the RELEASES D1
database, one part a step, and makes it the active release once it is the newest
publication loaded. A load that cannot finish leaves the active release as it
was. Every release a fixture names in apps/worker/pinned-releases.json stays
loaded, with the active one and the seven most recent; the rest are let go. A
store with no release at all is given those back from the archive before it
answers, and a store recreated for new tables, by whichever load, pass or read
touched it first, has them put back beside the release that caused it; a pinned release the store
lacks or failed to load is put back by the daily schedule, and just load release reloads any release by hand. A release published before the store
gained a table loads with that table empty. This store is what the read-only
EquipmentApi entrypoint answers from (#83); docs/API.md is the consumer's
side of it.
The build also emits vocabulary.json: the closed lists a consumer joins on
(metric and command kinds, equipment kinds, dialect families, confidence,
driver status, refuter status, brand decisions, the row tiers, the
dialect-model tiers, a property's basis, status and scope, a gap's reason,
a dialect kind's direction and a model key's origin), taken from packages/schema so a firmware crosswalk or
an assistant pins the words with the release rather than copying them.
Publication checks the Worker's history capability before uploading any file. An automatic publish on an older Worker fails explicitly and leaves the dataset untouched. A successful manual Worker deployment triggers publication again; failed deployments do not. History starts with that rollout; older runs are not backfilled. Retries within a publishing job repair its index without duplicating the event; a job rerun is a separate publication occurrence.