Skip to content

A subject id is a string, whatever the row is keyed by - #139

Merged
czpython merged 3 commits into
mainfrom
subject-id-is-a-string
Jul 28, 2026
Merged

A subject id is a string, whatever the row is keyed by#139
czpython merged 3 commits into
mainfrom
subject-id-is-a-string

Conversation

@czpython

@czpython czpython commented Jul 28, 2026

Copy link
Copy Markdown
Owner

A subject id is a string everywhere it is read — the URL segment, the DBOS attribute, the dedup key, the event row — while the row behind one is keyed by an integer. Nothing named that, so every summary bridged it by hand:

id=str(note.id)

And a constructor written for that one field went on to hand-copy the rest, which is how NoteSummary.from_note ended up listing five fields to transform one.

The identifier is a type

SubjectId = Annotated[str, BeforeValidator(str)]

Takes either and is always the string — the same settlement GraphQL, JSON:API and Protobuf all reached independently:

  • GraphQL's ID"often numeric, it should always serialize as a String", and "any string (such as "4") or integer (such as 4) input value will be accepted".
  • JSON:API"The values of the id, type, and lid members MUST be strings."
  • ProtoJSONint64 becomes a decimal string, "either numbers or strings are accepted", because JSON parsers are "silently lossy if a number is an integer larger than 2**53".

Scoped to id rather than ConfigDict(coerce_numbers_to_str=True), which would widen every string field on the model: a label or a title arriving as a number stays a mistake.

What it removes

from_attributes on the header means a subject builds its own. The platform default on both bases is now SubjectSummary.model_validate(self), and a note's summary is its columns rather than a constructor listing them — from_note is gone, and field_notes' schemas.py no longer imports its models.py at all.

What remains composes, and the next commit shows how little of it there was.

Not a wire change

id=str(item.id) and id=item.id serialize identically. No response shape moves, so the frontend is untouched.

Ship's own shapes follow

WorkItemSummary and DashboardItem each declared every field and then assigned every field, which buried the only two bits of real work: a project name reached through a relation, and a set of links.

project_name: str = Field(validation_alias=AliasPath("project", "name"))

@computed_field
@property
def links(self) -> Links:
    return Links.for_work_item(repo=self.repo, pr_number=self.pr_number, ticket_url=self.ticket_url)

The relation is an alias and the links compute from fields the shape already carries, so both build with model_validate and neither constructor survives. Links keeps the one that earns it — the URL rule, in one place, called by both.

Both serialize byte-for-byte as before. ticket_url rides DashboardItem only to feed its links and stays off the wire via exclude=True, and source_id keeps its name through a validation alias.

ProjectRepoSummary.from_repo is deliberately untouched: it runs repo.get_status(), one database query per repo on GET /api/ship/projects — measured at 12 for 12 repos. Making it declarative would hide that, not fix it, and the fields it derives are the run state the platform now serves on the repo's own board. Its own ticket.

@czpython
czpython force-pushed the subject-id-is-a-string branch from b76976d to 618631b Compare July 28, 2026 16:04
czpython added 2 commits July 28, 2026 18:05
A subject id is a string everywhere it is read — the URL segment, the DBOS
attribute, the dedup key — while the row behind one is keyed by an
integer. Every summary bridged that itself with `id=str(row.id)`, and a
constructor written for one field went on to hand-copy the rest.

`SubjectId` takes either and is always the string, the way GraphQL's ID,
JSON:API's id and ProtoJSON's int64 all settle it. With `from_attributes`
on the header, a subject builds its own: the platform default is
`model_validate(self)`, and a note's summary is its columns rather than a
constructor listing them. What is left composes — a name reached through
a relation, a URL, a title the row does not carry.
`WorkItemSummary` and `DashboardItem` each listed every field twice: once
as a declaration and once as an assignment in a constructor. What the
copying hid was the small amount of real work — a project name reached
through a relation, and a set of links.

The relation is a validation alias and the links are computed from the
fields the shape already carries, so both build with `model_validate` and
neither constructor survives. `Links` keeps the one that earns it: the
URL rule, in a single place, called by both.

Both responses serialize byte-for-byte as before; `ticket_url` rides the
dashboard item only to feed its links, and stays off the wire.
@czpython
czpython force-pushed the subject-id-is-a-string branch from 618631b to 4a297b2 Compare July 28, 2026 16:06
The dashboard item carried a `links` object no page ever opened — its one
consumer passes `prUrl={null}` — and the work item summary carried a
`ticketUrl` beside the `links.ticket` the UI actually reads. Both were the
same URL twice, or nobody's.

The dashboard item drops its links, the summary keeps the three the UI
opens and carries `ticket_url` only to build them, and the rule that
writes a GitHub URL moves to the one shape left that needs it.
@czpython
czpython enabled auto-merge (squash) July 28, 2026 16:13
@czpython
czpython merged commit 4ff9e18 into main Jul 28, 2026
2 checks passed
@czpython
czpython deleted the subject-id-is-a-string branch July 28, 2026 16:13
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