Skip to content

Forward compat - #90

Open
pshirshov wants to merge 23 commits into
mainfrom
wip/forward-compat
Open

pshirshov wants to merge 23 commits into
mainfrom
wip/forward-compat

Conversation

@pshirshov

Copy link
Copy Markdown
Member

No description provided.

pshirshov and others added 23 commits September 13, 2026 11:40
BaboonComparator now classifies, per evolution step and per type, whether an
older version's codec can decode newer-version data: IDENTICAL (byte-identical),
PREFIX_ANY_MODE / PREFIX_COMPACT (appended-only fields; top-level framed UEBA
reads), JSON_ADDITIVE (tolerant JSON readers), or nothing. Step tiers are
composed into contiguous suffix runs (BaboonEvolution.typesForwardReadable) via
an order-sensitive own-structure check plus a fixpoint over codec-relevant
dependencies; emitted in baboon-meta.json as "forwardReadable".

Scala + TypeScript backends emit the metadata as baboonForwardReadable
(per-type constant + BaboonGenerated member + BaboonMetadata lookup), with
end-to-end stub specs proving each tier against real cross-version blobs,
including negative controls.

Design: docs/drafts/20260911-0937-forward-compat-metadata.md.

Note: ForwardCompatComparatorTest documents a pre-existing sameIn overclaim
(deepSchemaRepr sorts flattened dep reprs, erasing member order inside deps;
a reordered-enum host stays 'unmodified' although its UEBA bytes change).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
C#, Python, Rust, Kotlin (+KMP), Java, Dart and Swift now emit the
baboonForwardReadable version->tier map as a per-type constant, a
BaboonGenerated(-equivalent) member, and a forwardReadableVersions(typeId)
registry lookup, mirroring the existing baboonSameInVersions surfaces.
Hand-written runtime-test stubs implementing the extended interfaces are
updated (C#/Kotlin/KMP/Java/Dart); Swift and Python use source-compatible
defaults overridden by generated code; Rust emits the real table where it
previously had only degenerate own-version sameIn data.

docs/forward-compat.md documents tiers, the prefix-* client contract, and
metadata locations. docs/ueba-format.md: corrected the enum wire description
(single positional u8, not i32 of the discriminant; const values never hit
the wire) — verified against the C#, Scala, TypeScript and Rust generators.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sameIn/unmodified overclaimed byte-identity: deepSchemaRepr sorted the
flattened dependency repr lines per field (and sorted ADT branch reprs),
erasing member/field/branch order inside dependencies and type-constructor
argument order — while UEBA is positional (enum discriminants, ADT branch
indices, field bytes, map K/V order). A host of a reordered dep enum, a
reordered dep ADT, or a dep with swapped map arguments classified as
'unmodified', so sameIn ranges promised byte-identity the wire does not have.

Fix: dependency reprs stay contiguous and internally ordered (determinism now
comes from sorting the dependency IDs instead of the flattened lines), each
field line carries its full type-ref rendering, and ADT branch order — the
UEBA discriminant order — is preserved. Reproduced fail-first in
ForwardCompatComparatorTest (EnumReorderHost / SumReorder / SumReorderHost /
MapSwapHost fixtures), which now pins the corrected classification.

Consequences:
- Lockfiles persist deepId-derived signatures: Locks now carries a 'scheme'
  marker (2 = this hashing; absent/1 = legacy). A stale-scheme lockfile is
  incomparable — enforcement is skipped for that one run and the file is
  re-signed in place even under create-only (T156 matrix extended).
- The M20 manual→sugared ADT rewrite is honestly classified: the sugared
  expansion reorders branches, shifting positional UEBA discriminants, so the
  step is deepModified with a derivable CopyAdtBranchByName conversion. PR-D's
  branch sort had masked this wire break; M20AdtEvolutionTest updated to
  assert the conversion-derivability guarantee instead of byte-identity.

Also adds .jvmopts (-Xmx8g -Xss8m): sbt's 1024 MB launcher default OOMs the
Scala.js parallel optimizer on many-core machines during 'sbt +test'
(reproduced on a clean clone of main, pre-existing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dReadPolicy — Scala/TS pilot

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… 9 backends

Every runtime now carries BaboonTypeMeta.domainVersionReadableMin (five-field
construction keeps readable-min = minCompat), writes/reads the JSON envelope
key $rv (elided when equal to the effective $uv), exposes the generated
per-type baboonMinReaderVersions (tier -> oldest reader) that feeds it, and
resolves JSON codecs under a facade-level ForwardReadPolicy (Tolerant default:
honour $rv for payloads newer than any registered version; Lossless: $uv only).
UEBA resolution is unchanged (v1 envelope has no slot for the bound).

Codegen: baboonMinReaderVersions emitted by all 9 DomainTreeTools / the Rust
facade generator. Dart test fakes gain the new provider getter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…acade meta tables

All nine binary envelope readers treated any hasMinCompat value other than 1 as
"elided", so an illegal flag byte (e.g. 0x02) was silently misparsed — the
min-compat string was consumed as the type identifier. codec-envelope.md §2.1
mandates rejection; readers now return None/null/undefined/Ok(None) for any
value other than 0x00/0x01. Reproduced fail-first in the Scala and TypeScript
stub suites (BaboonTypeMetaCodecSpec, TypeMetaFlagByte.test.ts), verified in
Python directly.

The TypeScript and Rust generated facade metadata registries emitted degenerate
own-version tables (sameInVersions = [own], forward = {own: identical}); they
now carry the real per-type sameIn / forward-readable tables like the other
backends (unknown type ids resolve to empty rather than fabricated data).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…TextTree

The TypeScript and Rust domain-facade generators and the GraphQL SDL
translator assembled generated code with StringBuilder appends and shipped
it through TextTree.verbatim / OutputFile(String). Every other emitter in
the compiler builds a TextTree; these three now do too (q-interpolated
templates, joinN/joinNN, shift/trim), with per-version, registration and
type-definition pieces as composable subtrees instead of imperative
append sequences.

GraphQL doc comments and the BaboonAny scalar description are interpolated
as verbatim nodes: plain String interpolation is escape-processed at render
time and stripMargin would strip `|` margins from user text.

Generated output is unchanged: schema.graphql files are byte-identical for
the whole test model directory, and the 36 TS + 36 Rust facades differ only
in the trailing newline, which now matches every other TextTree-rendered
file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…aders decode newer payloads with their newest codec

The v1 binary envelope has one bound slot, domainVersionMinCompat, and its
layout is frozen (a new field, a flag bit or a trailer would all be a format
change). Forward reads for UEBA therefore ride on the VALUE of that slot,
chosen by the writer through ForwardWritePolicy on BaboonCodecContext, next
to the index mode the bound depends on:

- Strict (default): the byte-identical bound, exactly as before.
- Tolerant: the prefix-read bound from baboonMinReaderVersions for the
  payload's index mode (prefix-compact for compact, prefix-any-mode for
  indexed). Equal to the Strict bound when no prefix relationship exists,
  so such envelopes stay byte-identical.

Readers in all nine runtimes now trust the bound: a payload from a version
newer than every registered one is decoded with the reader's newest codec as
soon as the bound reaches a registered version, instead of with the bound
version's codec via the sameIn scan. Readability is monotone along the chain,
so the newest codec is correct under both bound semantics and loses the
fewest fields. Reproduced fail-first over a new three-version shared fixture
(fwd-e2e-chain-ok): a reader registering 1.0.0 and 2.0.0 decoded a 3.0.0
envelope bound at 1.0.0 as ChainAppend(1) and dropped the 2.0.0 field.

The cost is documented in codec-envelope.md §2.1.2 and forward-compat.md:
a binary reader cannot tell a Tolerant envelope from a byte-identical one,
so ForwardReadPolicy.Lossless has no effect on binary reads and re-encoding
intermediaries must run at the writer's version or newer.

The Rust runtime crossed the JVM 64KB embedded-constant limit with this
change; BaboonTypeMeta and its wire codec moved to baboon_type_meta.rs and are
re-exported from baboon_codecs_facade.rs so every existing path still
resolves (CLAUDE.md records the symptom and the fix).

E2E coverage: ForwardCompatBinEnvelopeSpec (Scala stub) and
ForwardCompatBinEnvelope.test.ts (TS stub) — Strict unchanged/refused,
Tolerant compact decoded by an old reader, json-additive/identical/grown-enum
envelopes identical to Strict, indexed context not lowering a var-len append,
and the three-version chain decoded with the mid reader's newest codec.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…mbination

Adds a "Worked examples: what changed on the wire" section: the three knobs
(writer ForwardWritePolicy, index mode, reader ForwardReadPolicy) and the
reader-rule change; per-type writer bounds for the fwd-e2e fixtures; the real
JSON envelopes with both reader policies' outcomes; annotated UEBA bytes for
Strict vs Tolerant; the full value x context matrix as seen by a 1.0.0-only
reader; the three-version chain before/after the newest-codec rule; and a
summary matrix. All envelopes and outcomes were captured from the generated
Scala stub.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…very mode

Reworks the worked-examples section around the fixture types themselves: each
of FwdAppendVar, FwdMidInsert, FwdStable, FwdEnumHost and the three-version
ChainAppend is shown as its .baboon source across versions, its per-tier
bounds, the JSON envelope with both reader policies' outcomes, and the UEBA
envelope under Strict/Tolerant x compact/indexed with annotated bytes and the
older reader's result. All envelopes and outcomes were captured from the
generated Scala stub.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rs accept v1 and v2, writers default to v1

The v1 binary envelope has one bound slot, so a v1 reader cannot tell a
byte-identical read from a prefix read. metaVersion 2 is the JSON envelope's
field set in binary form:

  02 | domainId | domainVersion | flags | [minCompat] | [readableMin] | typeId | payload

flags bit 0: minCompat follows (elided when == domainVersion); bit 1:
readableMin follows (elided when == effective minCompat); any other bit is
rejected. minCompat is always the byte-identical bound and readableMin the
prefix bound for the payload's index mode, so ForwardWritePolicy is
irrelevant under v2 and the reader's ForwardReadPolicy applies to binary
exactly as it does to JSON: Tolerant decodes with the newest codec once
readableMin reaches a registered version, Lossless requires minCompat to.

The layout is selected per encode through BaboonCodecContext.envelopeVersion
(V1 default, V2 opt-in) in all ten runtime directories; the fully specified
context constructor gained the parameter. writeBin dispatches on
meta.metaVersion and fails fast on anything else; readMeta accepts 1 and 2 and
rejects other versions and unknown v2 flag bits. Binary getCodec now passes
the reader's policy through; v1 metas carry readableMin == minCompat, so their
behaviour is unchanged.

Reproduced fail-first in the Scala and TypeScript stubs: a hand-assembled v2
envelope was rejected by readMeta ("v2 envelope must be readable"). New specs
BinEnvelopeV2Spec / BinEnvelopeV2.test.ts cover the reader, the byte-identical
default, per-type flags and bounds in both index modes, round trips through
the writer and old readers under each policy, the three-version chain with
Lossless now enforceable for binary, and rejection of unknown flag bits and
metaVersions.

baboon_runtime.swift crossed the JVM 64KB embedded-constant limit;
BaboonVersion/BaboonDomainVersion/BaboonTypeMeta/BaboonTypeMetaCodec moved to
baboon_type_meta.swift in the same module (translator emits it; CLAUDE.md
records the pattern).

Spec: codec-envelope.md §1, §2.1.3 (new), §3 (byte 2 active), §5, §6.
forward-compat.md: v2 section, knobs, per-type v2 examples, summary matrix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adds the captured envelope-v2 bytes for FwdMidInsert and FwdEnumHost (flags 0:
the v1 bytes with 02 in front) so the per-type walk-through covers v2 for all
five fixture types, and explains how the v2 blocks relate to the shared v1
header. All v2 examples were re-captured from the generated Scala stub and
match the bytes asserted by BinEnvelopeV2Spec.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… sameIn dyn and Python multi-version facade

A byte-for-byte check of the binary envelope across backends (same values,
same contexts) found two asymmetries the per-language suites had not:

- Rust: the generated `baboon_same_in_versions_dyn` returned `[own version]`
  for every type while the Meta struct carried the real sameIn table. The
  envelope's byte-identical bound is the head of that run, so Rust-written
  envelopes of unchanged types (v1 and v2) elided the bound every other
  backend publishes — an older reader in any language refused them. The dyn
  impl now emits the real run (RsBaboonTranslator).
- Python: `_register_version` sorted by `v.version.version`, an attribute that
  does not exist, so no domain facade with more than one version could be
  constructed (no runtime test built one). Sort by `v.version`.

Every backend that had no envelope test — C#, Kotlin, KMP, Java, Python, Dart,
Swift, Rust — gains a golden-bytes suite asserting the exact sequences from
docs/forward-compat.md (v1 Strict, v1 Tolerant, v2 compact and indexed for
FwdAppendVar; FwdStable v1 and v2; FwdEnumHost v2; the three-version chain
v2), the v1/Strict default of the built-in contexts, and a v2 round trip
through the writer's own facade. Scala and TypeScript already assert the same
sequences structurally.

Swift: test/sw-stub/Package.swift is hand-written and had no targets for the
fwde2e.fwd / fwde2e.chain fixtures, so the Swift lane never compiled or tested
them (SwiftPM only warns about unowned Sources/ dirs). Targets, per-module
test targets and the RuntimeTests dependencies are added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adds §2.1.4 to codec-envelope.md, the v2 counterpart of the v1 conformance
block: annotated canonical bytes for FwdAppendVar (flags bit 1, readableMin
present) and FwdStable (flags bit 0, minCompat present), byte counts against
the v1 form, and pointers to the golden-bytes suites that pin them in every
backend.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
forward-compat.md gains a "Known gaps" section: no runtime e2e for
prefix-any-mode (and why), non-uniform missing-tier handling across runtimes
(and why it is unreachable for generated types), and the timestamp kind-byte
round-trip flake tracked as #91. CLAUDE.md records that test/sw-stub/Package.swift
is hand-written and silently skips fixture domains until their targets are added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ral fallback in the converter round-trip test

Missing-tier handling: Python, Swift and Rust fell back to the byte-identical
bound when baboonMinReaderVersions lacked the json-additive or prefix tier,
because their base types gave the member a default; the other seven runtimes
failed fast. The defaults are removed (abstract property / no protocol-extension
default / required trait method), the writers raise an encoder failure, the
Rust facade's type-meta construction returns Result instead of a bare value,
and the hand-written test fakes in the Swift and Rust stubs now provide the four
tiers. Fail-first tests: test_min_reader_tiers_fail_fast.py,
MinReaderTiersFailFastTests.swift, min_reader_tiers_fail_fast_tests.rs.

Timestamp kind byte (#91): the C# RpDateTime intentionally carries
its DateTimeKind on the wire and the Scala/JVM side is left unchanged, so a
tso/tsu is not byte-stable across writers by design. RTCodecTest therefore
falls back to a structural, equal-length comparison when the re-encoded bytes
differ, which is the property the round trip actually promises. Python wrote 2
(Local) for every non-zero offset; it now writes 0 like the other non-.NET
runtimes (test_timestamp_kind_byte.py). docs/ueba-format.md defines the byte.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Address the backend/runtime review across all targets with shared semantic plans, backend-local scalar and service emitters, and runtime-owned envelope and MCP helpers.

Fix renamed ADT branch evolution and source/target conversion validation, preserving custom validator contracts. Add behavioral regressions, compatibility coverage, and the implementation ledger.

Verified the full 205-action CI matrix, 200 serialization checks, and 162 RPC checks; repeated clean JVM/JS compilation, compiler tests, and native build before commit.
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