Skip to content
rlsbl.transition_record
On this page

An append-only committed log of repository-surgery facts, one kind per fact, written by the operation that performed it or declared by an operator. It never drives behavior.

#rlsbl.transition_record

#rlsbl.transition_record

Committed transition records: an append-only log of repository-surgery facts.

A TRANSITION RECORD is a JSONL file, one event per line, recording what a repository conversion actually did -- which tags were renamed, which commits a history rewrite moved, which tag globs departed with an extracted sub-project, which published identity changed and from which version, whose release history was deliberately closed, and which tags stand outside the version model on purpose. It is written by the operation that performs the surgery and read afterwards by anything that has to explain how the repository reached its current shape.

Two of the kinds have no such writer, because they are not things a command did: non-version-tag and release-history-closed are DECLARATIONS an operator makes about a repository they read, and nothing can derive either. rlsbl transition record (:mod:rlsbl.commands.transition_record_cmd) is their door.

It records history; it never drives it. Nothing here decides anything -- a reader consults the record to EXPLAIN a divergence it already observed.

Rename versus identity ----------------------

The record draws one line that every reader of it must hold, because the two sides get opposite treatment from rlsbl release reconcile:

  • A rename is a tag-SPELLING fact. A releasable renamed from widget to

gadget changes what its future tags are called and nothing a consumer resolves by: the artifacts already published keep the names they were published under. It is bookkeeping, recorded by :class:TagMapEvent and :class:BoundaryAliasEvent, and reconcile's identity refusal does NOT match on it.

  • An identity change is a change to the string CONSUMERS RESOLVE BY -- a Go

module path, a registry package name, a repository URL. Recreating an older release's ref after one would publish a version that shipped under the OLD identity under the NEW one, for the first time and permanently. That is what :class:IdentityTransitionEvent records, and reconcile's refuse-identity-mismatch matches on it with ZERO exceptions: no facet, no version and no operator flag turns the refusal off.

A rename must never be written as an identity transition to "be safe": doing so would make reconcile refuse to repair refs it is entitled to repair, permanently.

Where the file lives --------------------

One resolution function, :func:get_transition_record_path, decides the path, and all three locations hold the same format:

  • explicit-monorepo mode: inside the releasable's state directory,

.rlsbl-monorepo/releasables/<name>/transitions.jsonl -- pass releasable_dir (build it with :func:rlsbl.workspace_types.get_releasable_dir);

  • standalone repos, including a standalone successor produced by an extract:

<project>/.rlsbl/transitions.jsonl;

  • the workspace itself: <root>/.rlsbl-monorepo/transitions.jsonl -- pass

workspace=True.

The first two mirror :func:rlsbl.release_file.get_releases_dir exactly -- the same releasable_dir-or-.rlsbl fork, so the two state homes never drift apart.

:func:repository_transition_record_path picks between the second and the third by repository shape, and is the one resolution every reader and writer of a repository-scoped fact asks.

The third exists because some surgery facts are scoped to the REPOSITORY rather than to any releasable in it. A departure record is the case that forced it: when a releasable is extracted, its tag globs stop belonging to the source, and that is a fact about the source repository's tag namespace, not about a releasable that is no longer there. There is also nowhere else to put it -- a workspace has no <root>/.rlsbl/ at all (rlsbl's own root-rlsbl-conflict check refuses one beside .rlsbl-monorepo/), and picking some surviving releasable's record would file a repository-wide fact under an arbitrary releasable.

On-disk format and the append pattern -------------------------------------

One JSON object per line, each stamped with format_version as its leading key -- the same shape and the same append mechanics as the JSONL changelog (:func:rlsbl.changelog.files._append_entry_to_file): create the parent through the effect seam, then one :func:rlsbl.effects.append_lines carrying the whole batch. That helper is the single authority for the append mechanics both writers rely on.

What the append actually guarantees, stated precisely:

  • one append-mode write per :func:append_events call, so the batch reaches the

file in one operation and prior content is never read back and rewritten -- unlike the whole-file rewrite in :func:rlsbl.evidence_gate.write_undo_audit, where two writers can lose each other's record;

  • an event already on disk is therefore never modified or truncated by a later

append, whichever process performs it;

  • a torn last line (an interrupted write, a hand edit) cannot swallow the new

event: the existing file's final byte is inspected first and a separating newline is written when one is missing. The damaged line stays damaged and :func:read_events names it -- but only it.

What it does NOT guarantee: durability across a machine crash. There is no fsync, so an append that returned may still be in the page cache. The record explains history; it is not a transaction log.

That audit trail is where the append-record idea comes from; the line-per-event carrier is what lets a malformed record be reported by FILE AND LINE, and is the only carrier the strictspec per-line format_version gate applies to.

Validation and where errors fire --------------------------------

The strictspec-generated validator (rlsbl/strictspec_gen/transition_record_event_validator.py, schema .strictspec/transition-record-event.schema.toml) is the document authority for one line: the format_version gate, the kind discriminator and its arm set, field types, enums, required fields, and unknown-key rejection. rlsbl keeps only what strictspec cannot see -- whether a recorded SHA still resolves, whether a recorded tag still exists, cross-event related_to correlation.

There is no legacy mode. Every line rlsbl has ever written carries format_version = 1; a line without it, or with any other value, is a hard error. The format is new, so there is no pre-gate history to accommodate.

ERROR SITING: the hard error fires in :func:read_events, the point where a record is read FOR USE. Detection code that merely asks whether a repository has a transition record calls :func:transition_record_file_exists, which touches only the filesystem and can never raise on content -- so a malformed record breaks the one command that consumes it, never every command that walks the tree.

Two whole-file properties strictspec cannot see are enforced there too, because :func:read_events is the only place that sees every line at once: bytes that are not UTF-8 at all (reported as a record error naming the file and line, never as a bare :class:UnicodeDecodeError), and the id uniqueness the schema promises. Uniqueness is a READ-time check on purpose -- checking it at append time would mean reading the file before writing, which is exactly the read-modify-write window the append pattern exists to avoid, and it would still lose to a concurrent writer.

#TransitionRecordError

Malformed transition record, or an event that fails its schema.

#get_transition_record_path

python
def get_transition_record_path(project_dir: str='.', *, releasable_dir: str | None=None, workspace: bool=False) -> str

Return the path to a transition record.

releasable_dir is the releasable's state directory (.rlsbl-monorepo/releasables/<name>/); when given, the record sits directly in it, beside version and releases/. workspace=True selects the WORKSPACE-scoped record instead, <project_dir>/.rlsbl-monorepo/transitions.jsonl, for a fact about the repository rather than about one releasable (see the module docstring). With neither, it is the standalone home, <project_dir>/.rlsbl/transitions.jsonl -- which is also where a standalone successor produced by an extract finds its own record.

The two selectors are mutually exclusive: a record is either a releasable's or the workspace's, and asking for both names no file.

The file may or may not exist: an absent record means no surgery has been recorded, which is the normal state for a repository that has never been converted.

#repository_transition_record_path

python
def repository_transition_record_path(repo_root: str) -> str

The record a fact about THIS REPOSITORY belongs in, by repository shape.

A workspace has no <root>/.rlsbl/ at all, so a repository-wide fact goes in the workspace-scoped record; a standalone repository has only the one.

This is the single resolution for the question, and every reader and writer of a repository-scoped fact asks it: rlsbl transition record writes the two operator-declared kinds here, rlsbl release backfill's unexplained-tag refusal names this file, and :func:rlsbl.targets.refs.ref_context adds it to the records the TAG-NAMESPACE question consults.

#transition_record_file_exists

python
def transition_record_file_exists(path: str) -> bool

True when a transition record file is present at path.

DETECTION ONLY. It reads no content and validates nothing, so scanning code that runs on every command can ask this without a malformed record turning into a repository-wide hard error. The error belongs at the read-for-use site, :func:read_events.

#new_event_id

python
def new_event_id() -> str

Generate a unique transition record event id.

Timestamp-prefixed UUID4 hex, so ids sort approximately by creation order without an external dependency: <16 hex ns><32 hex uuid4>.

This deliberately mirrors rlsbl.changelog.schema.generate_entry_id rather than importing it. A later phase has the changelog reading transition record release commit remaps, and importing the changelog package from here would close that loop into an import cycle. Two independent record systems each owning their own id generator is the cost of keeping them independent.

#now_timestamp

python
def now_timestamp() -> str

Current local time as RFC 3339 with a UTC offset, to the second.

The schema declares recorded_at as an offset datetime, so the offset is mandatory and +02:00-style -- not the +0200 that time.strftime produces.

#TransitionRecordEndpoint

One side of a conversion: which repository, and which slice of it.

#TagMapping

One old-tag -> new-tag correspondence.

#ReleaseCommitMapping

One old-SHA -> new-SHA correspondence produced by a history rewrite.

#BoundaryAlias

One alias tag created at a conversion point.

#SplitMapping

One monorepo-commit -> subtree-split-commit correspondence.

#_TransitionRecordEventBase

Fields every transition record event carries.

id and recorded_at are optional at construction and stamped by :func:append_events, so a writer states only the fact it is recording.

#ConversionEvent

A sub-project extracted out of a workspace, or a repository absorbed in.

#TagMapEvent

The tag renames a conversion performed.

#ReleaseCommitRemapEvent

The old-SHA -> new-SHA correspondence a history rewrite produced.

#DepartedGlobsEvent

Tag globs that stopped belonging here because their sub-project left.

#BoundaryAliasEvent

Alias tags created at a conversion point.

#IdentityTransitionEvent

A published identity changed, effective from a stated version.

#ReleaseHistoryClosedEvent

A member's or releasable's release history is deliberately closed.

Operator-declared, through rlsbl transition record --release-history-closed <subject>.

Read by the releasable-residue check, which reports the release archives, changelog directory and version tags of a member that releases nothing: a subject with a recorded closed history has left a deliberate RECORD of what it released rather than residue, so the check exempts it instead of proposing that it be moved or deleted.

#NonVersionTagEvent

A tag deliberately outside the version model.

Operator-declared, through rlsbl transition record --non-version-tag <tag>. Read by :mod:rlsbl.tag_explanation, so rlsbl release backfill stops listing the tag as unexplained and rlsbl release reconcile stops owing a verdict on it.

#ReleasableRenameEvent

A releasable group was renamed.

A tag-SPELLING fact, on the same side of the rename-versus-identity line as :class:TagMapEvent and :class:BoundaryAliasEvent: the releasable's future tags are spelled with the new name, the artifacts already published keep the names they were published under, and nothing a consumer resolves by has changed. rlsbl release reconcile's refuse-identity-mismatch therefore does not match on it.

Written by rlsbl monorepo rename-releasable beside the boundary alias it creates, and declarable through rlsbl transition record --releasable-rename <old> --to <new> by an operator who renamed one another way.

#PromotionSplitMapEvent

The subtree-split correspondence persisted when a mirror is promoted.

#_plain

python
def _plain(value)

Recursively convert nested value dataclasses to dicts, dropping None.

#serialize_event

python
def serialize_event(event) -> str

Serialize one event to a single JSON line (no trailing newline).

format_version leads (the per-line gate), then kind, then the event's own fields in declaration order. None-valued optional fields are omitted, so a line carries exactly the facts that were stated.

#_build_nested

python
def _build_nested(cls, raw, field_name: str)

Construct one nested value dataclass from a raw JSON object.

#parse_event

python
def parse_event(line: str)

Parse one JSON line into the event dataclass its kind selects.

Raises :class:TransitionRecordError on malformed JSON, a missing or unsupported format_version, an unknown kind, a missing required field, an unknown key, or any other schema violation. There is no tolerant mode: a record that cannot be read is never half-read.

#_gate_line

python
def _gate_line(line: str) -> None

Run the strictspec per-line format_version gate.

Unlike the changelog, absence is an error too: the transition record format was born with the gate, so there is no legacy line to accommodate and no config key that could turn enforcement off.

#_validate_line

python
def _validate_line(line: str) -> None

Validate one line's full shape through the strictspec validator.

#append_events

python
def append_events(path: str, events) -> list

Append events to the transition record at path, in the order given.

Each event is stamped with an id and a recorded_at when it does not carry them, validated, and written as one line. The stamped copies are returned, and a caller that needs the ids it just wrote (to reference them from a later event's related_to) reads them off the return value.

COPY SEMANTICS, exactly: each returned object is a TOP-LEVEL copy (dataclasses.replace), so stamping never touches the caller's own objects -- but nested values are SHARED, not copied. source, destination and the mappings lists (with the mapping objects inside them) are the very objects the caller passed. Deep-copying them is not worth the cost on a release commit remap that can carry thousands of mappings, so the contract is: do not mutate a nested value after appending it, because the written line no longer reflects it and the returned copy will follow the mutation.

The write is one append through the effect seam, carrying the whole batch, creating the parent directory when missing. Prior content is never read back and rewritten, so a concurrent writer cannot be clobbered and an already-written event is never modified. The one thing read first is the existing file's final byte: when the file is non-empty and does not end in a newline -- an interrupted write, a hand edit -- a separating newline leads the append so the new event starts its own line instead of being concatenated onto the damaged one. The damaged line stays damaged; :func:read_events will name it.

Every event is validated BEFORE anything is written, so an invalid event in the batch aborts the whole append rather than leaving a partial record.

#append_event

python
def append_event(path: str, event)

Append one event. Returns the stamped copy as written.

#_undecodable_bytes_error

python
def _undecodable_bytes_error(path: str, exc: UnicodeDecodeError) -> TransitionRecordError

Turn a raw decode failure into a record error that names the file.

The offsets on a :class:UnicodeDecodeError raised by a text-mode read address the decoder's current buffer, not the file, so the offending line is located by one pass over the raw bytes. That pass only ever happens on the error path, which already ends the read. A line the pass cannot pin down still yields a named error -- the file, without a line number -- rather than letting a bare decode traceback out.

#read_events

python
def read_events(path: str, *, kinds=None) -> list

Read the transition record at path and return its events in order.

An absent file yields an empty list: no record means no surgery was ever recorded, which is the normal state.

THIS IS THE READ-FOR-USE SITE, so this is where malformed content is a hard error. Any unreadable line -- bytes that are not UTF-8, bad JSON, unknown kind, missing required field, wrong format_version -- raises :class:TransitionRecordError naming the file and the line number. So does an id that repeats one already used in the file: the schema calls the id unique within the file, and this is the only place that sees the whole file. kinds filters the RESULT, never the validation: a malformed or duplicate line of a kind the caller did not ask for still stops the read, because a record that cannot be read in full cannot be trusted in part.

Search