Skip to content

Refuse a client budget limit typed into anything the build reads [#89] - #169

Merged
iderex merged 1 commit into
mainfrom
client-budget-numbers-in-one-file
Aug 16, 2026
Merged

Refuse a client budget limit typed into anything the build reads [#89]#169
iderex merged 1 commit into
mainfrom
client-budget-numbers-in-one-file

Conversation

@iderex

@iderex iderex commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

What was wrong

The design system page states five numbers a native client is held to, and they
are the pinned token copy's:

jq -r '.budget.numbers | to_entries[] | "\(.key)\t\(.value.comparison) \(.value.limit) \(.value.unit)"' data/design-tokens.json
focus-change	below 80 ms
dropped-frames	equal 0 frames
first-picture	below 1200 ms
layout-shift	equal 0 shifts
playback-start	below 2000 ms

Nothing refused a second copy of one of them written into what the build reads.
The row over a typed token value covers colours, lengths, font stacks and
weights, and a latency ceiling is none of those:

go run . invariants | grep design-tokens-live
  design-tokens-live-in-exactly-one-file: ok, 6 file(s) of every tracked file the build reads to render a page

Run at 3f8cf5d, with the sentence A key press answers under 80 ms on the clients this file is read by. appended to content/design-system.txt. The row
above reports ok over it, and so does the whole run.

That is the same failure the colour row exists for with a different unit, and it
is the harder half to see. A wrong colour looks wrong on the page. A wrong
millisecond looks like every other millisecond, so the copy goes on being
rendered perfectly after the published value has moved, and a client meets a
target the file no longer states.

What this does

A row, client-budget-numbers-live-in-exactly-one-file, over every tracked file
the build reads to render a page. The same sentence appended to
content/design-system.txt, at the commit this pull request pushes:

go run . invariants
  client-budget-numbers-live-in-exactly-one-file: REFUSED, 1 violation(s)
    it refuses a limit a client is held to, written into what the build reads, in either the words the page states it in or the number and its unit alone
    because a number a client is held to is the same class of fact as a spacing step, and it is the harder one to see when it goes stale: a wrong colour looks wrong on the page and a wrong millisecond looks like every other millisecond, so a second copy of one is a conformance target somebody meets while the published one says something else
    content/design-system.txt: line 20 writes "under 80 ms", which is what data/design-tokens.json says focus-change is, and data/design-tokens.json is the one file it is read from

and with the sentence taken back out:

go run . invariants | grep client-budget
  client-budget-numbers-live-in-exactly-one-file: ok, 6 file(s) of every tracked file the build reads to render a page

Three things about the row are worth reading rather than inferring.

It is handed the numbers rather than carrying them. A row is given the bytes of
one file and nothing else, so Rules now takes the numbers the copy carries.
Every other row ignores what it is given, and the table holds the same rows
however it is handed, which is what lets the count the gate prints come from a
call that supplies nothing. A row holding the five values in its own source
would be the second declaration of the file it exists to keep as the only one.

A run reads the copy before it builds the table, and refuses a tree whose copy
carries no such number, or one whose limit has no unit or no comparison beside
it. A row handed nothing refuses nothing, and a green mark from it over an empty
set would read as a tree holding no second copy when what it means is that
nobody said what the numbers are. That is the position gather already takes
about the copy existing at all, one step further in.

How a limit is spelled is decided in one place. tokens.Number.Stated is what
the page prints and what the row looks for, so the two cannot part company: a
page and a row each carrying their own spelling would leave the row reporting ok
over the words the page had started using. The page's output is unchanged by
that move:

sed -n '/What a native client has to meet/,/<\/table>/p' dist/design-system.html | grep -E '<td>' | sed 's/^ *//' | paste - - -
<td>dropped-frames</td>	<td>exactly 0 frames</td>	<td>Frames dropped scrolling across a row of 200 tiles at 60 fps. A single drop is visible.</td>
<td>first-picture</td>	<td>under 1200 ms</td>	<td>App start to the first usable tile, from a cold start. Later than that and the app reads as loading rather than opening.</td>
<td>focus-change</td>	<td>under 80 ms</td>	<td>Key press to the first moved scanline. Above that the remote feels sluggish.</td>
<td>layout-shift</td>	<td>exactly 0 shifts</td>	<td>Elements that move after they are first painted. The tile&#39;s size is fixed before the image arrives, so a shift means something was not held.</td>
<td>playback-start</td>	<td>under 2000 ms</td>	<td>Selection to picture, on direct play over the local network. Beyond that the selection stops feeling like it was received.</td>

What the row does not reach, stated rather than left to be found. It compares
against the values it was handed, exactly, so a limit written in another unit
walks through: under 0.037 s is not under 37 ms to it. That is the bound the
colour row declares for itself for the same reason, and it is what keeps the
file rather than the row as the authority for the set. A pattern over any number
with a time unit would refuse a transition duration instead, which is a value
the token file holds none of and the page would legitimately carry.

The other half of the issue is the test asking that a value changed in the
pinned copy move exactly that number on the page. It is in internal/site, over
a fixture copy rather than the real one, and a second case covers the comparison
deciding how the limit is stated.

Closes

Closes #89.

What was run

go run . ci
gate: 7 legs, in order: format, vet, test, build, links, sitemap, invariants
  needs-network was not asked for. Asking costs a request to the public name from whatever machine runs it, and a verdict that moves when somebody else's service does rather than when this tree changes. Ask with: go run ./harness/needs-network
  format: ok, 52 file(s)
  vet: ok
  test: ok, 25 test file(s)
  build: ok, 8 file(s)
  links: every reference that stays inside this site resolves to a file the build wrote
  sitemap: every page the build wrote is listed once, and every entry has a page behind it
  invariants: ok, 37 rule(s) decided, 1 owed and not decided
7 of 7 legs ran. None was skipped.

The needs-network set was not asked for, which is the line above, and nothing
in this change is about it. No test was skipped, and none of the six headless
constraints is reached by anything here.

The guard is proven by taking it away. With the row's decision replaced by one
that returns nothing:

go test ./internal/invariant -count=1
--- FAIL: TestEveryRowRefusesItsOwnViolationAndPassesTheNeighbour (0.00s)
--- FAIL: TestRunRefusesALimitTypedIntoWhatTheBuildReads (0.42s)
--- FAIL: TestABudgetNumberTypedIntoAPageRedsExactlyOneRow (0.00s)
FAIL

and with it back:

go test ./internal/invariant -count=1
ok  	github.com/Flowfin/site/internal/invariant	8.103s

The cases the row is judged by, each one a statement the others do not make:

go test ./internal/invariant -run 'Budget|Table|Limit|Copy' -count=1 -v | grep -E '^--- (PASS|FAIL)'
--- PASS: TestRunRefusesALimitTypedIntoWhatTheBuildReads (0.27s)
--- PASS: TestRunAcceptsAFrameThatQuotesNoLimit (0.25s)
--- PASS: TestRunRefusesACopyThatCarriesNoClientBudget (0.52s)
--- PASS: TestRunRefusesACopyWhoseLimitCarriesNoUnit (0.27s)
--- PASS: TestTheBudgetRowRefusesBothSpellingsAndReportsTheLongerOne (0.00s)
--- PASS: TestTheBudgetRowLeavesANumberThatIsNotABudgetNumberAlone (0.00s)
--- PASS: TestTheBudgetRowFollowsTheCopyRatherThanCarryingTheNumbers (0.00s)
--- PASS: TestTheBudgetRowHandedNothingRefusesNothing (0.00s)
--- PASS: TestTheTableHoldsTheSameRowsHoweverItIsHanded (0.00s)
--- PASS: TestABudgetNumberTypedIntoAPageRedsExactlyOneRow (0.00s)

and the page half:

go test ./internal/site -run 'Budget|Comparison' -count=1 -v | grep -E '^--- (PASS|FAIL)'
--- PASS: TestThePageStatesBothBudgetsAndSaysWhichIsWhich (3.99s)
--- PASS: TestABudgetNumberChangedInTheCopyChangesExactlyThatLineOnThePage (0.17s)
--- PASS: TestTheComparisonInTheCopyDecidesHowTheLimitIsStated (0.06s)
--- PASS: TestABudgetNumberMissingWhatItMeansIsRefused (0.17s)

The output is unchanged by the change to how the page reaches its spellings:

go run . reproduce
reproduce: two builds of ., compared byte for byte
  8 file(s), identical in both builds

The fourth condition of the issue was already met and is re-read here rather
than taken from an earlier reading:

sed -n '32,33p' decisions/0007-where-the-design-tokens-live.md
The numbers a client has to meet travel the same way for the same reason. They
are published by the design system page today and held by nothing at all, and

The second sentence there describes what was measured on 2026-08-09 and has
since moved, which is recorded on the issue. Nothing in this change edits that
record.

go run . hygiene origin/main HEAD
hygiene: 1 non-merge commit(s) in origin/main..HEAD, origin internal
  1357ffda5bdf: subject carries its reference
1 commit(s) judged, none refused.

git diff --name-only origin/main...HEAD
CHANGELOG.md
internal/gate/gate.go
internal/invariant/invariant.go
internal/invariant/invariant_test.go
internal/site/designsystem.go
internal/site/designsystem_test.go
internal/tokens/tokens.go

internal/invariant/invariant.go is the file every row lands in, and this
change moves the signature of Rules as well as adding a row, so a branch that
adds a row on top of main meets it there. That is disclosed rather than
avoided: the signature is where the values reach a row, and there is no other
route.

The means

Go, which is what the tree is made of, and no dependency is added: the module
graph is still empty. The row carries a refusable property decided by the same
table every other rule is in, its bite is shown above by deleting it, and every
number this body states carries the command that produced it. The spelling
helper is on the token package rather than on the page, because the page and the
row both need it and a value that reaches two callers from one place is what the
whole rule is about.

Who read it

Nobody but me. The ruleset on this branch requires no approving review, so this
is merged by whoever opened it, and the evidence above stands in place of a
second reader rather than beside one.

The design system page states five numbers a native client is held to, and
they are the pinned token copy's. Nothing stopped a second copy of one of
them from being typed into a template or into the prose beside it. That is
the failure the row about a typed colour exists for with a different unit,
and it is the harder half to see: a wrong colour looks wrong on the page and
a wrong millisecond looks like every other millisecond, so the copy goes on
being rendered perfectly after the published value moves.

A row cannot be told anything except the bytes of one file, so the table is
now handed the numbers it compares against. Every other row ignores what it
is given, and the table holds the same rows however it is handed, which is
what lets the count the gate prints come from a call that supplies nothing.
A run reads the copy before it builds the table and refuses a tree whose copy
carries no such number, because a row that compared nothing would report ok
and read as a tree holding no second copy.

How a limit is spelled is now decided in one place, in the token package, and
both the page and the row ask there. A page and a row each carrying their own
spelling would part company the day either moved, and the row would go on
reporting ok over the words the page had started using.

Removing the row's decision reds three cases in the suite, and the two
spellings, the numbers it leaves alone and its following the copy rather than
carrying the numbers each have a case of their own.

Signed-off-by: Nils Lehnen <nils77sz@gmail.com>
Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com>
@iderex iderex added the enhancement New feature or request label Aug 16, 2026
@iderex iderex self-assigned this Aug 16, 2026
@iderex iderex added this to the The pages milestone Aug 16, 2026
@iderex
iderex merged commit 503245f into main Aug 16, 2026
17 checks passed
@iderex
iderex deleted the client-budget-numbers-in-one-file branch August 16, 2026 23:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Take the client budget numbers from the same place the tokens come from

1 participant