Skip to content

Let a record name the field that tells one from another - #34

Merged
andyyhope merged 5 commits into
mainfrom
feature/record-keys
Aug 20, 2026
Merged

Let a record name the field that tells one from another#34
andyyhope merged 5 commits into
mainfrom
feature/record-keys

Conversation

@andyyhope

Copy link
Copy Markdown
Owner

A record can name the field that tells one from another

@FlagRecord
struct Endpoint {
    @FlagRecordKey var name: String
    var url: URL
}

flags.endpoints["staging"]?.url

A key is what makes one record a different record rather than an edited one. Two
sharing it has no correct behaviour — picking one would leave the app running on a
value nobody chose — so a list containing a duplicate is unreadable and falls back
to the flag's default, as any other malformed value does. A payload or a document
carrying one is refused outright:

'config.endpoints' → endpoints: expected every record to have its own name,
got two with "staging"

The companion enforces the same rule rather than writing what the app will not read:
a colliding edit is refused the way an unparseable one is, and "Add" gives a new
record a distinct key. The key also titles each row, which was otherwise the first
declared field and therefore a guess.

It marks the field rather than being an argument on @FlagRecord, because a key
path cannot be written there. \.name fails as cannot infer key path type from
context
— an attached macro's generics are not bound to the type it is attached to —
and \Endpoint.name fails as circular reference resolving attached macro. Marking
the field also removes the repetition, and follows @FlagGroup, which is read the
same way.

A container can say what its flags are for

@FlagContainer(description: "Everything the checkout team can turn on")
struct AppFlags {  }

Shown above the flags, read from the schema like everything else — so a host that
adds one needs no companion rebuild, and one that says nothing looks as it did.
Hidden while searching, since a set being filtered is not the set any more.

Sizing

Single-line fields are leading aligned again, and use one fixed small size rather
than scaling to fit — scaling made a field's text size a function of its contents,
so a column arrived at three sizes and none matched anything else on screen. They
stay full width: a value too long for a narrow box could only be read a fragment at
a time, and a URL is exactly the value you need whole.

The record and array rows carry their count and preview at footnote and caption. At
body size they sat under the flag's description and key and read as a second title.

Adversarial review

Two defects, both in the same place: the editor could still write a list the host
refuses.

A date key collapsed on the way out. Keys were compared as they are held rather
than as they are written, and a Date carries more precision in memory than the wire
format keeps. Two records added within the same second looked distinct to every check
and identical to the reader — so Add twice wrote a list, and the flag reverted to its
default when the host read it. Keys compare by stored identity now, and Add advances
a date key by a second rather than giving up.

setRecords only checked the top level. A nested list with a repeated key was
accepted and made the whole outer flag unreadable, since the host applies the rule at
every depth. Now checked at every depth — and the check asks for the duplicate rather
than for the records, because reading them applies this very rule and hands back
nothing, so the first attempt looked straight past what it was looking for.

Seeing it on device also caught a message that had stopped being true: a list whose
records share a key is a well-formed list breaking a different rule, and both the row
and the editor called it "not a list of records", which starts a search for a syntax
problem that is not there.

Verified

721 tests on a clean build. DocC clean with --warnings-as-errors, both example apps
build, and every change here was looked at on a simulator rather than inferred from a
passing test.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NnwCqKNm8rZ63Au6bpYdaD

andyyhope and others added 5 commits August 20, 2026 10:20
    @FlagRecord
    struct Endpoint {
        @FlagRecordKey var name: String
        var url: URL
    }

    flags.endpoints["staging"]?.url

A key is what makes one record a different record rather than an edited one. Two
sharing it has no correct behaviour — picking one would leave the app running on a
value nobody chose — so a list containing a duplicate is unreadable and falls back
to the flag's default, as any other malformed value does. A payload or a document
carrying one is refused outright: "expected every record to have its own name, got
two with "staging"".

The companion enforces the same rule rather than writing what the app will not
read. An edit that would collide is refused the way an unparseable one is, and
"Add" gives a new record a distinct key — without that, pressing it twice would
write two records sharing an empty key and the flag would silently revert on the
second press. The key also titles each row, which was otherwise the first declared
field and therefore a guess.

It marks the field rather than being an argument on '@FlagRecord' because a key
path cannot be written there, which is worth recording: '\.name' fails as "cannot
infer key path type from context", since an attached macro's generic parameters are
not bound to the type it is attached to, and '\Endpoint.name' fails as "circular
reference resolving attached macro". Marking the field also removes the repetition
and follows '@FlagGroup', which is read the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnwCqKNm8rZ63Au6bpYdaD
…left

    @FlagContainer(description: "Everything the checkout team can turn on")
    struct AppFlags { … }

The companion shows it above the flags, and reads it from the schema like
everything else — so a host that adds one needs no companion rebuild, and one that
says nothing looks exactly as it did. The application name answers "whose flags are
these"; this answers "what are they", which is the question someone handed an
unfamiliar debug build actually has. Hidden while searching, since a set being
filtered is not the set any more.

Single-line fields are leading aligned again, like the block editor beside them. A
value is read from its start — a URL by its scheme and host, a number by its
leading digits — and every field beginning in the same place is what lets a column
of them be scanned rather than read one at a time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnwCqKNm8rZ63Au6bpYdaD
The row already carries the flag's description and its key, so the count and the
field list are the fourth and fifth things in it. At body size they competed with
the description for the eye. Sized to footnote and caption they read as what they
are.

Seeing it on device also turned up a message that had stopped being true. A list
whose records share a key is a perfectly well-formed list breaking a different
rule, and both the row and the editor called it "not a list of records" — which
starts a search for a syntax problem that is not there. They now say which key is
shared, and which two records share it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnwCqKNm8rZ63Au6bpYdaD
The array row carried its count and preview at body size, so like the record row
before it, the two lines under a flag's description read as a second title rather
than as a count and a taste of the value.

Its preview still truncates in the middle where the record row truncates at the
tail. An array preview is bracketed, so keeping both ends shows that it is a list
and where it stops; a record's field list has no brackets, and cutting through the
middle of it produced "enabled:…ue".

Single-line fields now use one fixed size rather than scaling to fit. Scaling made
a field's text size a function of its contents, so a column of them arrived at
three different sizes and none matched anything else on screen. They stay full
width: a value too long for a fixed narrow box could only ever be read a fragment
at a time, and a URL is exactly the value you most need to read whole.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnwCqKNm8rZ63Au6bpYdaD
**A date key collapsed on the way out.** Keys were compared as they are held, not
as they are written, and a Date carries more precision in memory than the wire
format keeps. Two records added within the same second looked distinct to every
check here and identical to the reader — so pressing Add twice wrote a list, and
the flag went back to its default when the host read it. Keys are compared by
stored identity now, everywhere that asks whether one is taken, and "Add" advances
a date key by a second rather than giving up on it.

**setRecords only checked the top level.** A nested list with a repeated key was
accepted and made the whole outer flag unreadable — the host applies the rule at
every depth. It is checked at every depth now.

The nested check asks for the duplicate rather than for the records: reading them
applies this very rule and hands back nothing, so the first attempt looked straight
past the thing it was looking for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnwCqKNm8rZ63Au6bpYdaD
@andyyhope
andyyhope merged commit a8c35fe into main Aug 20, 2026
6 checks passed
@andyyhope
andyyhope deleted the feature/record-keys branch August 20, 2026 05:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant