0024 — the registry is public, and the sample is stored¶
Status: accepted Date: 2026-08-03 Supersedes: nothing. Extends 0012 (conversion provenance).
The problem¶
The kernel could write conversion provenance and could not read it back out.
A delivery record says 12 bag -> 1200 kg and cites a conversion_id. That
citation is the mechanism the whole platform rests on: it is what lets a lender
distinguish a quantity somebody weighed from a quantity somebody assumed. But
until now there was no route that resolved a conversion id to anything. The
apparatus was write-only.
That is not a missing endpoint. It breaks the thesis. The claim is that a third party can verify a record without trusting ClyCites. A weight they cannot dereference to a factor, a basis and a sample is a weight they have to take on faith, which is the situation the platform exists to end.
What was actually stored¶
Correction to a premise that had been circulating: migration 0012 did not
record twelve weighings. It added sample_size, sample_min, sample_max,
sample_stddev, condition and local_label — six summary columns. The twelve
numbers behind them existed in the seed fixture and in an English sentence in
source. measured_by, measured_at and instrument did not exist at all.
Four statistics cannot be recomputed into the sample they came from. So an endpoint that returned everything the registry held would still not have let anybody check the summary, because there was nothing to check it against. Migration 0015 therefore had to create the storage before the read path could be worth having.
Decisions¶
The sample is rows, not a JSON column¶
registry.unit_conversion_sample, one row per weighing, keyed
(conversion, ordinal), immutable under the same refuse_mutation trigger as
every other registry table, with a trigger refusing an ordinal beyond the
declared sample_size and refusing any row against a factor that declares no
sample. A JSON blob would have made "the summary restates the sample" an
assertion in application code. As rows it is a database constraint.
condition is per sample, not per factor. Two of coop A's twelve bags were
weighed damp; that is not noise to be tidied away, it is the explanation for a
2.68 kg standard deviation, and a single factor-level dried,tight would have
concealed it.
A superseding row, not a backfill¶
The provenance was attached to a new conversion
019fc600-…-000000000051, superseding …0050, rather than added to …0050.
Two reasons, and the second is the real one:
- The immutability trigger refuses
updateonregistry.unit_conversioneven to the schema owner. Working around it would have meant disabling it. - Records already cite
…0050. Attaching evidence to that row would retroactively give those records provenance they did not have when they were written. A record must keep the meaning it had at the moment of writing.…0050remains readable, and says what replaced it.
The registry is unauthenticated and ungated¶
GET /v1/registry/** takes no subject header, passes through no consent guard,
and is served with Cache-Control: public, max-age=86400, immutable.
The registry holds unit conversions, crop codes, administrative boundaries and
grading vocabularies. There is no data subject anywhere in it. Gating it would
be theatre — and worse than theatre, because verification that requires our
permission is not verification. This is the first surface where the kernel's
openness is a feature rather than a risk, and the reason it is safe is
structural: the controller can inject RegistryRepository and nothing else, and
that repository holds select on the registry schema and nothing else. A test
in test/ops/exposure.test.ts fails if the controller ever reaches a record
service.
Collections do not carry samples¶
A single-conversion fetch returns the sample. A collection does not. A lender filtering a list does not need twelve rows per factor; a scraper would like them very much. One fetch by id gets them.
Rate limiting, and what it is not¶
The public surface is limited per address by an in-process fixed window
(REGISTRY_RATE_LIMIT, default 600 per minute). This is stated in
rate-limit.middleware.ts as a finding rather than hidden: the counter lives in
one process, so it is per replica, it resets on deploy, and behind N instances
the effective limit is N times the configured one. It is adequate for a
single-instance kernel and is not a substitute for a limit at the edge. When the
gateway grows one, this becomes the second line.
Consequence for the export guard¶
test/ops/exposure.test.ts forbids bulk export. A registry collection endpoint
is bulk, and the guard did not fire on it — its route-name regex does not match
conversions. Rather than add an exception, the guard was read again and
narrowed to what it always meant: no bulk export of personal data. The s.16(4)
reasoning it encodes — that an export makes the recipient an independent holder
we owe correction notices to indefinitely — has no purchase on data with no
subject.
Its real defect was elsewhere. The second test checked a hardcoded list of three controllers, so a fourth would have been silently exempt. That list is now derived from the directory.
Consequence for the lender view¶
The report now resolves every cited factor and prints measured (n=12) beside
assumed_default, with the twelve weights, the officer, the date and the
instrument underneath the first and "a convention, not a measurement" underneath
the second. Both cooperatives quote 100 kg per bag. Only one of them has ever
checked.
That contrast is the artifact this kernel exists to produce.