DESIGN — Native reflection bridge (Phase 4 operational)
Status (2026-07-24): Phases 1, 2, and PHASE-3 INCREMENT 1 (the write-back half) are SHIPPED. Increment 1 landed via the §6.1 chip (session
optimistic-bassi-496625; design + adversarial-review ledger inDESIGN-reflection-bridge-phase3-plan.md), validating errors (#34, 61/61 vsgo test -json) as its demonstrated consumer. Companion to the Phase-4 operational campaign in../Roadmap.md.⚠ “Phase 1/2/3” below are THIS DOCUMENT’s scope phases (§ Scope), unrelated to the project’s Phase-3 / Phase-4 milestones.
Implemented —
manualConversionFuncsinsrc/go2cs/manualTypeOperations.gois the authoritative list; derive it fresh rather than trusting this summary: the Kind classifier + type helpers (golibGoReflect:KindOf,GoTypeName,ElementType,IsComparable,TryAdapterWrappedType),internal/abi.TypeOf(type_impl.cssynthesizes the descriptor from the managedSystem.Type),reflect.{ValueOf, unpackEface, valueInterface}, the ~17Valuereaders +MapIter.{Next,Key,Value}
Value.{MapKeys, MapIndex}(2026-08-09, § below),rtype.{String, Name, Elem, Field, NumField}, canonical internedValue.Type/toType(canonType— the map-key-ordering fix, § below),deepValueEqual(deepequal_impl.cs), theinternal/reflectlitemini-bridge (ValueOf/Len/Swapper), and thesynthType.Equalcomparability signal (encoding/csv, 2026-07-21).Phase-3 increment 1 (SHIPPED 2026-07-24, the chip): the ONE new primitive — an addressable Value carries the
ж<T>box it aliases (addrBoxcompanion; reads go through the box lazily,Setwrites through its slot ref via cached compiled accessors overValueSlot) — plusreflect.{Value.Set, Zero, methodName}and the reflectlite errors.As surface (Value.{Elem,IsNil,Set},rtype.{Elem,Implements,AssignableTo},methodName), all over shared golib machinery (GoReflect.{TryMarshalAssignable, GoImplements, ReadPointerSlot/WritePointerSlot, CanonicalNilPointer, GoDynamicTypeOf}) so reflection and emitted_<T>asserts can never disagree about a method set. With it landed: canonical typed-nil pointer boxing (X2 — converter + golibж<T>.NilBox+ gen; see ConversionStrategies-ReferenceCanonical typed-nil pointer boxing), structural nil-pointer identity (INilPointer; heap-box-holding-nil ≠ nil pointer), the R10 adapter unwrap (GoDynamicTypeOf— descriptors classify the Go dynamic type, so adapter-held and raw-box values intern to ONE canonical Type), the golib assert-fallback unwrap + ж-overload closing (X1) with the non-genericTryTypeAssert(object, Type, out object), dyn-wrapperIInterfaceAdaptertransparency + the[GoRecv]method-set fix (X3), Go-parity empty-name subtest numbering (X4), the oracle’s two-phase address-token pairing (X5), and%v-of- method-bearing-pointer fidelity (handleMethods wins, never&-prefixed). ThegetcallerspNotImplementedExceptionchain is severed atmethodName— the semantic boundary where a managed answer exists.Phase-3 increment 3 (SHIPPED 2026-07-26) — the type-relation mirrors + conversion. The deferred “reflect-side
Implements/AssignableTomirrors” row landed with its demonstrated consumers (encoding/gob’s init viavalidUserType → implementsInterface, go/token’sTestSerialization, internal/fmtsort’sct()table): hand-ownedrtype.{Implements, AssignableTo, FieldByName},PointerTo,Value.{Convert, Cap, SetLen}— each severing a descriptor-SPECIALIZATION read (Reinterpret<abi.Type, interfaceType/structType>, the ptrType prototype, the cvt/unsafe_New chain) that cannot exist behind a synthesized descriptor — plus theStructField.Indexstamp (gob’sFieldByIndexwalk), golib pointer order tokens (INilPointer.PointerOrderToken: equal pointers token equally, same-storage elements order by index — fmtsort’s map-key ordering),runtime.Pinner.Pin/Unpinas no-ops by construction, and-testsinit-order relocation (the erasableinitᴛᴛtestsstatic-ctor hook; see ConversionStrategies-Reference *Package-Level Variable Initialization Order). Validated: internal/fmtsort 3/3, go/token 31/31; gob runs its Encoder/Decoder engines end-to-end (full gob validation remains open).Phase-3 increment 3a (SHIPPED 2026-07-26) —
Value.Addr,rtype.PkgPath, and the EMPTY interface. Found by running encoding/gob’s own 98-Test suite (68 → 79 passing). Three roots, each general:
Value.Addrderived its pointer TYPE throughptrTo → typesByString → typelinks()— the linker-built, string-sorted type table, a runtime intrinsic with no managed form (its stub throws). EveryAddrtherefore threw, taking out ELEVEN gob GobEncoder round-trip tests. But the bridge already holds the address: an addressable Value ALIASES theж<T>box its storage lives in (addrBox), soAddrsurfaces that box as a Pointer-kind Value, andElemon the result re-enters the same box — Go’sv.Addr().Elem() == vcontract (#32772) by construction.rtype.PkgPathread the descriptor’sTFlagNamedbit anduncommon().PkgPathname-offset, which a synthesized descriptor never populates, so it answered""for EVERY type — gob’sRegisterthen keyed its wire-type registry on the bare"N2"instead of"encoding/gob.N2"(TestRegistrationNaming). The managed nesting carries the package identity: the declaring<pkg>_packageclass names the package and the enclosing namespace names its parent directories (GoReflect.GoPackagePath). Not a strict inverse for a major-version directory (math/rand/v2→ recovers"math/rand") or a module dependency renamed away from its path segment; exact everywhere else.- The EMPTY interface is
object, which reportsIsInterface == false.GoReflect’sGoImplementsandTryMarshalAssignableboth gated their interface arms onIsInterface, soAssignableTo(t, interface{})andSetinto ananyslot answered NO for every type — gob’sdecodeInterfacerejected every concrete value it had just decoded, thenreflect.Value.Setrejected it again (“gob: int is not assignable to type interface {}” → “reflect.Set: value of type int is not assignable to type interface {}”). Both now carry the explicittypeof(object)armKindOfandTryConvertToalready had. Cleared gob’s TestInterfaceBasic / TestInterfacePointer / TestNestedInterfaces.✅ gob’s build blocker is CLOSED (2026-08-02, r37-gob) and gob is MEASURED — the “79 of 98” above is superseded by 86 of 106. It was build-blocked when increment 5 looked:
package_info_internal_test.csemitted[assembly: GoImplement<gob_internal_test_package.Point, Pythagoras>], butPythagorasis declared in the EXTERNAL test package (example_interface_test.go,package gob_test) and gob declares a SECOND, unrelatedPointthere — so the record anchored the internal variant’s same-named type and left the interface unqualified in a file where it is not in scope:CS0246, no test host, all 106 verdicts empty. That was test-project-model record anchoring (thesplitWhiteboxVariantRecordsfamily), not reflection surface: a BARE record name resolves in the variant that RECORDED it, and the bridge’s declared-name set was being consulted across variants. Fixed and guarded — seeConversionStrategies-Reference.md, A BARE record name resolves in the variant that RECORDED it.Measured, one run, zero empty verdicts: 86 of 106 match (C# 81 pass + 5 skip vs Go 101 pass + 5 skip; 19 capability-excluded, 0 disclosed). Full per-root census — seven roots, only one of them this arc’s descriptor surface — is the
encoding/gobsection ofBOARD-next-validation-candidates.md.Rooted gob residues owned by THIS arc (open, NOT disclosure candidates) — 3 of the 20. The managed
array<T>type does not carry its LENGTH, soreflect.Type.Elem()of a*[N]Tloses N and gob sees the wrong type where the wire says[7]int—TestSingletonsandTestIndirectSliceMapArray(Value.Elem/abi.TypeOfrecover dims from the LIVE value, but a type-only walk cannot).TestIgnoreDepthLimitisreflect.ArrayOf→ thetypelinksstub. A fourth is adjacent and worth this arc’s attention even though it is not the descriptor surface: the five remaining GobEncoder tests share ONE root that is not reflection at all — theirGobDecodebodies write through a reinterpreted named-type pointer,fmt.Sscanf(s, "VALUE=%s", (*string)(v)), and the reinterpret is emitted as a value conversion into a fresh box (Ꮡ((@string)(v))) becausereinterpretManagedEmissionis gated on a deref context or a raw-address source, neither of which a pointer conversion used as an ARGUMENT satisfies. The direct-field-write case (ByteStruct,TestGobEncoderStructSingleton) passes, soAddr’s write-back path is sound.TestNetIPwas never blocked onnet’s package init (that claim is retracted —fd_windowsis not on its stack): it dies inunique’s initializer, where r37-gob fixed a dead deref alias ininternal/concurrentand thereby exposed the root behind it —abi.TypeOf(m).MapType()over a zero map yields a descriptor with noHasher, soNewHashTrieMapthrowsDelegate to an instance method cannot have null 'this'. That one IS this arc’s surface. The rest are gob wire/typed-nil behaviours, all bucketed on the board.Phase-3 increment 4 (SHIPPED 2026-07-31) — the
getcallerspchain:runtime.Callers+Frames.Nextmanaged. The chip’s io increment. The failing surface was never the flatten OPTIMIZATION (pure Go, converts fine) but the tests’ MEASUREMENT of it:runtime.Callers→callers()opens withgetcallersp()(assembly) andFrames.Nextreads linkerfuncInfotables. Both contracts are answered natively inruntime/managed_impl.csoverSystem.Diagnostics.StackTracewith a GO-LOGICAL frame projection — only source-declared Go functions count; adapter shells (IGoAdapter) and go2cs-gen forwarders ([GeneratedCode]) are invisible, exactly as Go’s interface dispatch adds no frame — so relative depths match Go’s logical model (io’sreadDepth == myDepth+2holds bit-exactly); PCs are opaque interned process-lifetime tokens.getcallerspitself stays an honest stub — the chain is severed at the API boundary where a managed answer exists, themethodNamerule. (⚠ Follow-up 2026-08-07, r43g-caller: this increment hand-owned the EXPORTEDCallersonly, andruntime.Callercalls the lower-casecallers— soCalleritself stayed dead, which is what keptlogandtesting/slogteston the stub.methodNamewas unaffected, being hand-owned in its own right. The lower-case funnel is hand-owned too now, one entry, andCallerworks while staying auto-converted.) io: 45/54 → 47/54; the remaining seven verdicts keep their non-reflection owners (os arc, StringCheckCall, two alloc-profile disclosure rulings). Full design: ConversionStrategies-Referenceruntime.Callers / Frames.Next walk the managed stack projected to GO-LOGICAL frames. (Same landing repairs the whitebox adapter-pair resolver — a bare cast of a variant-local type resolved to the first same-simple-name FOREIGN record (bytes_BufferжReaderfor io_test’s ownBuffer, CS1503 ×20), which had silently re-walled the io build the board records as closed; anchor-local records now win, guarded byTestBareCastPrefersAnchorLocalRecordOverForeignSimpleNameMatch.)Phase-3 increment 5 (SHIPPED 2026-08-02) —
reflectlite’srtype.String. The chip’scontextincrement, and the quietest descriptor read yet:String()ist.nameOff(t.Str).Name(), a name OFFSET into the linker-built name blob that a synthesized descriptor never populates — so everyreflectlite.TypeOf(x).String()in the corpus answered"", and answered it without faulting, because""is a legal name for an unnamed type.context’sstringifyfallback printedWithValue(, c1k1)where Go printsWithValue(context_test.key1, c1k1). Bridged ininternal/reflectlite/type_impl.csoverGoReflect.GoTypeName— the same answerreflect’s long-hand-ownedrtype.Stringand%Tgive, so the full bridge and the mini bridge cannot disagree about a type’s name — with array dims threaded as on thereflectside. Blast radius is three call sites (the fix plus two panic messages);rtype.Nameis untouched and still""(it gates on theTFlagNamedbitsynthesizeDescriptornever sets), anderrorsnever reachesString()at all.context: 36/38 → 37/38, leaving only the measuredTestAllocsalloc-count disclosure its banking commit owns. Full design: ConversionStrategies-Reference The type NAME is a descriptor read too. Recorded next gap of the same shape:rtype.Name— deliberately not fixed without a consumer that demonstrates it.Phase-3 increment 6 (2026-08-02) —
rtype.NumMethod, the method-set SIZE. The chip’stimeincrement, and the same silent-degradation class as increment 5:NumMethodcountsuncommon()method tables a synthesized descriptor never populates, so it answered 0 for every concrete type — silently, because 0 is most types’ correct count. The consequence hid one hop away:encoding/json’sindirect()gates itsUnmarshaler/TextUnmarshalerdiscovery onv.Type().NumMethod() > 0, so no customUnmarshalJSON/UnmarshalTextwas EVER dispatched — everyjson.Unmarshalintotime.Timefell through to the raw-struct path (TestTimeJSON), and{}decoded silently whereTime.UnmarshalJSONrejects it (TestUnmarshalInvalidTimes). ONE root, no further layers: with the gate answering, the increment-1/3a machinery (field-aliasAddr,Interface, the golib assert, write-back through the aliased box) carries the whole dispatch, struct fields included. Hand-owned inreflect/value_impl.csover golibGoReflect.GoMethodCount→TypeExtensions.GoMethodSetCount, counted overGetGoMethodSetCandidates— the SAME candidate sourceStructurallyImplementsand the shell binder resolve through, so the gate and the assert behind it cannot disagree about a method set; deduplicated by projected Go name (one pointer-receiver method has two emitted shapes), exported-only for concrete types, ALL methods for interfaces, 0 for the empty interface, adapter shells unwrapped per R10. Guard:JsonUnmarshalerDispatch(four dispatch shapes vsgo run). Full design: ConversionStrategies-Reference The method COUNT is a descriptor read too. Recorded next gap of the same shape:rtype.Method(i)— still auto over the same absent tables, and aNumMethod() > 0gate now lets method-enumeration loops get further than before; the first consumer that walks one demonstrates it.Phase-3 increment 6 + 7 (SHIPPED 2026-08-03 as ONE increment) — the METHOD TABLE:
NumMethod,Method(i),MethodByName, and the method VALUE. Increment 6 shipped the COUNT alone (rtype.NumMethod, gate-clean,time’sTestTimeJSON/TestUnmarshalInvalidTimesas its demonstrated consumers) and was reverted from master: the all-package sweep caughtmath/randandmath/rand/v2’sTestRegressfailing withpanic: reflect: Method index out of range— the successor gap increment 6’s own report had recorded, arriving one session later. The durable lesson, worth generalizing beyond this bridge: a truthful count is a PROMISE that the table behind it can be indexed.NumMethodreadsuncommon()method tables a synthesized descriptor never populates, so it answered 0 for every concrete type; while it did, every method-ENUMERATION loop was unreachable and the autoMethod(i)reading the same absent tables could not be observed. Making the count truthful is exactly what made them reachable. A descriptor read and the gate in front of it are one atomic increment.Landed together:
rtype.{NumMethod, Method, MethodByName}andValue.Method, all over ONE ordered list (TypeExtensions.GetGoMethodSetEntries) whose.CountISNumMethod— a size and an order can no longer be derived separately and disagree — built on the sameGetGoMethodSetCandidatesthe duck-typing assert and shell binder resolve through, deduplicated by projected Go name (keeping the delegate-bindable shape), exported-only for concrete types, and sorted ordinally by Go method name (Go’s own table order; a promoted embed sorts in place). A method value is an ordinary BOUND DELEGATE, which is the design’s economy: binding the receiver atMethod(i)time makes the result a Kind-Func Value, somv.Type(),NumIn/In/NumOut/Outandmv.Call(args)are all existing bridge surface unchanged — the receiver is already gone from the signature, exactly Go’s method-value contract — andValue.MethodByNameneeds no hand-own because it composes the other two. Binding is expression-compiled becauseDelegate.CreateDelegatecannot close over a value-type first argument (measured), which every Go value receiver is; one compile perMethodInfo, a closure per bind.Found on the way and fixed in the table builder: a
this objectextension method is golib plumbing, never a Go method. The candidate source’s assignability safety net admits them for EVERY type, soTryCastAsInteger(this object, out ulong)was in every method table — nondeterministically, since a late assembly load re-runs the scan: the same binary reportedNumMethod4 or 6 for the same type depending only on load order. The shipped-then-reverted count was therefore also wrong in a way nothing could observe.Consumers:
math/rand43/43 andmath/rand/v236/36 at their exact banked counts, withTestRegressnow genuinely walking (*rand.Rand NumMethod: 16in Go’s order,Intn(1000000000) = 526058514matchinggo run; before the pair it read 0 and the test passed VACUOUSLY — zero of its 320 golden comparisons ran).time: 146 → 148 pass of 159, the two increment-6 JSON rows re-landed; its 9 remaining failures are the timer-model item (TestChan×8) and theTestUnmarshalTextAllocationsdisclosure ruling, neither this arc’s. Guard:tests/Behavioral/ReflectMethodTableWalk. Full design: ConversionStrategies-Reference …and the count and the WALK are ONE increment.Phase-3 increment 8 (SHIPPED 2026-08-03) — the ZERO test, and a read that had degraded to a CONSTANT. The chip’s
encoding/gobincrement.Value.IsZerois three descriptor reads over flat memory (anEqualpointer against the sharedzeroValbuffer, aTFlagRegularMemoryall-bits-zero scan, andv.ptr == nilfor a non-flagIndirvalue); a synthesized descriptor populates none, and the bridge populates neitherv.ptrnorflagIndir, so the Array and Struct arms both fell tov.ptr == niland answered true for every array and every struct whatever it held. Silent, like every member of this family —trueis correct for the zero value of the same type. Measured againstgo runbefore the fix:[2]uint8{1,2},NA{1,2},inner{N:1},outer{P:&n}allIsZero=truein C#,falsein Go.Three things landed together because each gates the next. (1)
Lenunwraps a named string — every other named container answers through the golib interface its wrapper implements, but atype NS stringwrapper implements none, soLenfell to its0default andIsZero’s String arm (Len() == 0) called every non-empty named string zero. The increment-6 rule in a second form: the arm is a GATE onLen, so the gate and the read behind it are one increment. (2)IsZerobecomes Go’s own recursive definition with the memory shortcuts removed — a composite is zero exactly when every element or field is, which is precisely the walk the shortcuts stand in for (Go itself falls back to it for a non-comparable, non-regular-memory type), so it needs onlyIndex/Field/NumField, already answered. (3)Value.Growreads a*unsafeheader.Sliceoff the same never-populatedv.ptrand therefore nil-deref’d for every caller —reflect.ValueOf(&s).Elem().Grow(1)on[]byteprints4 8in Go and panicked here; it is now a managed reallocation (golibGoReflect.GrowSlice) written back through the aliased box exactly asSetLendoes. Growth within capacity writes nothing (Go reachesgrowsliceonly past the capacity, and a spurious write would detach another view sharing the backing store), and the landed capacity is deliberately unpinned — Go’sgrowslicerounds to a size class, so onlylen+nis guaranteed. Guard:tests/Behavioral/ReflectZeroAndGrow, byte-identical togo run.The
MapType().Hasherrow is rooted and deliberately NOT landed — populating it would be worse than the failure.unique’s one-root wall (15 of 19) andnet’s last cctor root is a map descriptor withHasher/Key/Elemunpopulated. It looks like the same shape as every read above and is not:Hasher(unsafe.Pointer, uintptr) uintptrmust hash the value at an address, and the address that call site produces cannot name a managed value. Measured three ways: two boxes holding equal@stringvalues necessarily have different addresses, so an address-derived hash can never makeunique.Make("hello")agree with itself; a box whose pointee contains a reference has no pinnable slot and its address moved across a forced GC; and theunsafe.Pointerhanded to the delegate retains no link to its source box, its constructor taking auintptr. Key/elem types are recoverable from the carriedSystem.Type, but landing those alone is a regression:Key.Equalis the comparability SIGNAL (pointer identity, not value equality), so a half-populated descriptor converts a loud construction failure into a map that silently mislays every key — the increment-6 lesson inverted: a descriptor field whose read cannot be honored must not be populated to look truthful. The remedy is one layer down and outside this arc’s declared files:internal/concurrent.HashTrieMapis a managed-referent raw-metal case whose CONTRACT (a concurrent map over comparableK) the CLR answers natively while its MECHANISM (hash bytes at an address) it cannot — a hand-owned_impl.cson thesync.Mutexprecedent. Needs a coordinator ownership ruling before it is written.The map READ pair landed 2026-08-09 (r57b), with
go/astas its first validator.Value.MapKeysandValue.MapIndexwere the two map methodsMapRange/MapIter.*/SetMapIndexleft behind, and both still ran their converted bodies: each opens withv.typ().Reinterpret<abi.Type, mapType>()to read the key/element types off the embeddedabi.MapType. That is NOT the managed-box aliasing casetoRTyperelies on — a synthesized descriptor is a bareabi.Typewith noabi.MapTypebehind it, and the emittedmapTypeholds that embed as a promoted REFERENCE box (ᏑʗMapType), so the reinterpret reads the descriptor’s first word as an object. Both then index throughmapaccess/hiter, whichMapRangehad already replaced.MapKeysis nowMapRangecollected;MapIndexis a golib comma-ok lookup (GoReflect.TryGetMapEntry, routed throughIMap<K,V>rather than the non-genericIDictionaryso the nil key in golib’s dedicated slot is found like any other). One key/element typing rule now serves the walk and the lookup alike, and a miss returns the invalid zero Value thattext/template’sfuncs.goalready tests withIsValid().Unnamed struct types render STRUCTURALLY (same arc).
reflect.Type.String()/%Tof a converter-LIFTED anonymous struct reported the lift’s C# name (ast_internal_test.typeᴛ1) where Go printsstruct { X int; y int }, and golib’sEmptyStructwhere Go printsstruct {}. The[GoType("dyn")]stamp is what distinguishes a lift from an ordinary declared struct, and the arm inGoReflect.TypeNamingbuilds the text fromGoFields— the same projectionNumField/Fieldand the value side read, so a type’s reported NAME and the fields it hands out cannot disagree. Format verified against the toolchain (embedded field contributes its type alone; a tagged field appends thestrconv.Quoted tag). This partially answers open question 3 on the READ side.The two Value-layer gaps asn1 and edwards25519 measured, closed (L7, 2026-08-11). Both are descriptor reads that had degraded to a CONSTANT, and both were rooted against the real code rather than the board’s attribution — which was half right in the first case and pointed one layer off in the second.
- Exportedness —
StructField.PkgPath.IsExported()is nothing butPkgPath == "", and the hand-ownedrtype.Field(i)leftPkgPathunset, so it answered TRUE for every field of every converted struct.encoding/asn1opens both its struct arms withif !t.Field(i).IsExported() { return StructuralError{"struct contains unexported fields"} }, soMarshalreturned a nil error andUnmarshalran past the refusal into the unexported field and panicked inmustBeAssignable. That panic is the evidence for the actual root: the two halves of the read-only model had degraded INDEPENDENTLY —Value.Fieldalready stampedflagStickyROfromGoReflect.GoFields, soCanSet()was correct and the write WAS refused; only the type-side descriptor had no answer. The board’s “flagRO propagation inValue.Field” therefore names the half that was already right.PkgPathnow derives from the same projection’sExportedbit plusGoReflect.GoPackagePath.Offsetstays unpopulated on the r39d rule (a Go byte offset exists to be added to a data pointer);Anonymousis the recorded next gap, and wants ONE increment with the field ORDER beside it — go2cs-gen emits a promoted-embed backing box AFTER the declared fields, sostruct{X; y; Inner; inner; Ptr}walks asX, y, Ptr, Inner, innerhere where Go walks it in declaration order. Guard:tests/Behavioral/ReflectUnexportedFieldFlags.- Fixed-size-array synthesis —
[GoArrayDims]on the parameter.testing/quickallocates its argument from the parameter type alone, andreflect.TypeOf(f).In(0)of a[32]byteparameter answered a dims-less array (Len()0,String()"[]uint8"), soNew/Zerobuilt a zero-length one. The root is NOT in quick or in the bridge’s array construction: it is that a func parameter is the one position no dims source reaches — no value to measure, no field initializer to read, and a delegate type (Func<array<byte>, bool>) shared by everyfunc([N]byte) bool. So the converter now stamps the dimension at the parameter andGoReflect.FuncParamDimsreads it back off the delegate INSTANCE (Delegate.Method.GetParameters()— verified to resolve for method groups, non-capturing and capturing lambdas, natural-typed lambdas and local functions), whichabi.TypeOfcarries as descriptor cargo andrtype.In(i)consumes. The cargo joins both interning keys, orfunc([32]byte) boolandfunc([64]byte) bool— distinct Go types over ONE managed delegate type — would share a descriptor. RESULT dims are deliberately not carried (a multi-result Go func returns aValueTuple, which has no per-element attribute position; no measured consumer readsOut(i).Len()), and a bridge-minted method value keeps the dims-less descriptor because its expression-compiled target carries no attributes. Guard:tests/Behavioral/ReflectFuncArrayParamDims. Corpus footprint over all 557 behavioral packages: 3 files, 6 declarations.NOT implemented — remaining Phase-3 surface:
MakeFunc; variadicCall/CallSlice(text/template);SetMapIndexdelete-on-invalid (encoding/json); the Go unnamed↔nameddirectlyAssignablerefinement beyond identity+wrapper (binary named-slice cases if they surface);FieldByName’s embedded-field depth search (a promoted name currently answers the not-found path); open question 3 (field-name/tag fidelity —[GoTag]is carried but not yet projected). Known limitation (recorded): named func types collapse to their structuralFunc<>/Action<>undercanonTypeSystem.Type interning — carried named-func identity is needed if a consumer (gob’s type registry) lands on it.
The problem
Go’s reflect is built on reading an interface’s internal two-word layout through unsafe.Pointer.
An any value is an eface = { *abi.Type type; unsafe.Pointer data }; reflect reinterprets the
address of the interface as that struct, reads the type word (a pointer to the runtime type
descriptor abi.Type), and reads/writes the value through the data word as flat memory at
computed field/element offsets.
None of that exists in the managed world. A go2cs any is a System.Object reference — one
word, no adjacent type descriptor, no flat-memory value the GC will let you address by offset.
Reinterpreting the object reference as {type,data} reads garbage and NREs. Concretely, the first
operational hit is:
color.New(FgGreen).Println("…")
→ fmt.Sprint(a…) → fmt.doPrint → reflect.TypeOf(arg).Kind() // spacing decision
→ internal/abi.TypeOf(any a): ~(ж<EmptyInterface>)(uintptr)(Ꮡ(a)) // reinterpret → NRE
(Plain fmt.Println sidesteps this: doPrintln never calls reflect.TypeOf; only doPrint — used
by Print/Sprint/Sprintf fallbacks — does. That is why the fmt.Println("hi") milestone runs.)
Key insight — the entire unsafe chain enters at THREE constructors
A full read of the reflect/internal/abi/fmt surface (see the surface map below) shows the whole
model bottoms out on one primitive — the eface {type,data} reinterpret — reached at exactly
three points:
| # | Function | File | What it does today |
|---|---|---|---|
| 1 | internal/abi.TypeOf(any a) |
internal/abi/type.cs:125 |
&a → *EmptyInterface → read .Type word |
| 2 | reflect.unpackEface(any i) (→ ValueOf) |
reflect/value.cs:156 |
&i → *EmptyInterface → read .Type + .Data |
| 3 | reflect.toType(*abi.Type) |
reflect/type.cs:3040 |
wraps a descriptor pointer as an rtype |
Every downstream Type/Value method then reads from those words — Type.Kind() masks
abi.Type.Kind_; Value.Int() does ~(ж<int64>)(uintptr)(v.ptr); Value.Field(i) does
add(v.ptr, offset); etc.
So the bridge is: replace those three constructors so they carry a System.Type (+ the boxed
object for a Value) instead of two raw words. The ~30 downstream methods fmt needs then become
ordinary managed-reflection wrappers (obj.GetType(), FieldInfo.GetValue, array indexing,
Convert.ToInt64, IDictionary enumeration) with no unsafe anywhere.
Architecture — a native reflect/abi shim over System.Type + System.Object
Hand-own the reflection entry points and the exercised methods (whole-file
[module: GoManualConversion], the established pattern — cf. native sync, atomic.Value), so:
reflect.TypeOf(x)returns aTypecarryingx.GetType()(aSystem.Type), not aж<abi.Type>descriptor pointer.reflect.ValueOf(x)returns aValuecarrying(object box, System.Type).internal/abi.TypeOf(x)returns a compatible handle (orreflectstops calling it — TBD, see Q4).- Each
Type/Valuemethod is reimplemented over managed reflection.
The one genuinely new primitive is the Kind classifier — System.Type → reflect.Kind — the root
of every method. It reads go2cs’s own representations and attributes:
| Go Kind | go2cs C# representation | detect |
|---|---|---|
| Bool / Int* / Uint* / Float* | bool,nint,int,long,byte,double,… |
typeof |
| Uintptr | uintptr (golib struct) |
typeof(uintptr) |
| Complex128 | System.Numerics.Complex |
typeof |
| String | @string |
typeof(@string) |
| Slice / Array / Map / Chan / Pointer | slice<T>/array<T>/map<K,V>/channel<T>/ж<T> |
open generic typedef |
| Func | GoFunc / delegate |
IsSubclassOf(Delegate) |
| Struct | [GoType] value struct (no num:) |
[GoType] + IsValueType |
| Interface | Go interface → C# interface | IsInterface |
| UnsafePointer | @unsafe.Pointer (: ж<uintptr>) |
typeof |
named type Celsius float64 |
[GoType("num:float64")] struct Celsius |
Kind = underlying; Name/String from the type |
The metadata the shim recovers Go type info from is already emitted: [GoType(def)] (struct/
interface marker + num:<kind> for named numerics), [GoTag] (= DescriptionAttribute, the raw Go
struct-field tag), [GoRecv] extension methods (the method set), and the golib generic types. C#
FieldInfo enumeration returns fields in declared (= Go source) order.
Scope — three phases, each independently useful
Phase 1 — TypeOf().Kind() / .String() (unblocks the color sample + scalar Print/Sprint).
doPrint calls reflect.TypeOf(arg).Kind() for every arg; the value itself formats via the fast
path or the Stringer/error/Formatter C# interface assertions in handleMethods — which use no
reflection at all. So a Type shim exposing Kind(), String(), and Elem() (byte-slice check),
plus the Kind classifier, makes fmt.Print/Sprint/Sprintf work for every scalar and every type
with a String() method. ~1 shim type, ~4 methods, +1 classifier. This is the minimal color-sample
unblock.
Phase 2 — full printValue walk (composites without a String() method format correctly).
Add the Value shim (~21 methods actually exercised: Kind, IsValid, CanInterface, Interface, Type,
Bool, Int, Uint, Float, Complex, String, IsNil, NumField, Field, Elem, Index, Len, Bytes, CanAddr,
UnsafePointer, Pointer), Type.{Elem, Field(i).Name}, a MapIter (Next/Key/Value over
IDictionaryEnumerator, for fmtsort), and StructField.{Name,Tag,Type}. ~2 core shim types +
MapIter + StructField, ~30 methods, all managed reflection. Makes %v/%+v/%#v of structs,
slices, maps, and pointers correct.
Phase 3 — write-back & call (Value.Set*, Value.Call, MakeFunc, addressability) — OPEN; the
chip’s scope. Needed by encoding/binary, encoding/gob·json·xml, testing/quick,
text/template, and (transitively) math/big; not by fmt. Larger, and best designed against a
concrete consumer — which is exactly why the charter (§6.1) spawns this chip only once a package’s
differential actually lands on this surface, and requires designing WITH the user + adversarial design
review before implementation. Carry the getcallersp stub and the adapter-type Kind/Elem unwrap
in the same chip.
Open design questions (for review)
Resolved by what shipped (2026-07-22): Q1 — the native shim was confirmed and built; the descriptor-synthesis alternative was not pursued. Q2 — both Phase 1 and Phase 2 were built. Q4 — the answer was both, deliberately: the entry points and exercised methods are whole-file hand-owned
*_impl.cs, while the reusable managed logic (Kind classification, Go type naming, element types, comparability, adapter unwrap) lives in golibGoReflectsoreflect,internal/abi,internal/reflectlite, and golib’s ownbuiltinformatting all share one implementation. Q3 is still open and belongs to the Phase-3 chip. Q1–Q4 are kept below as the record of the decision.
-
Approach — confirm the native shim. Replace the 3 constructors + reimplement the exercised methods over
System.Type/System.Object(recommended). The alternative — synthesize faithfulabi.Typedescriptors fromSystem.Typeand keep Go’s convertedreflectreading them viaunsafeoffsets — is judged infeasible (the offset reads themselves don’t work in managed memory; it is strictly more surface than the shim). -
Scope to build now — Phase 1 only, or Phase 1 + 2? Phase 1 unblocks the color sample and all scalar/Stringer formatting for the least work; Phase 2 makes composite formatting correct. Phase 3 is deferred regardless.
-
Field-name / tag fidelity.
fmt’s%+v/%#v(and laterjson) need the exact Go field name + tag. C# field names are the Go names except where escaped (@string) or Δ-collision-renamed;[GoTag]already carries the tag. Options: reverse-map the escapes at the shim, or have the converter emit a per-struct Go-name table (a small attribute) the shim reads. (Phase 2 concern.) -
Where the shim lives. Hand-own
reflect+internal/abi.TypeOfas whole-file[module: GoManualConversion](consistent with nativesync/atomic.Value), or put the managed logic in a golibGoReflecthelper that the convertedreflectdelegates into (smaller hand-owned footprint, but a converted↔golib seam through the shim). Recommend whole-file hand-own of the entry points, since the value/type model is pervasively unsafe and not worth converting.
Surface map (reference)
abi.Type fields: Size_, Kind_ (the field everything keys off), TFlag, Str/PtrToThis
(offset-encoded), PtrBytes/Hash/Equal/GCData (unused by fmt). Kind enum (values 0–26) is
defined identically in internal/abi/type.cs:40 and reflect/type.cs:245. fmt calls only
Type.{String, Kind, Elem, Field(i).Name} and the ~21 Value methods tabulated in Phase 2.
handleMethods (fmt/print.cs:795) formats Stringer/error/GoStringer/Formatter via C#
interface assertions — no reflection. Fast-path printArg types (no reflection):
bool, int/8/16/32/64, uint/8/16/32/64, uintptr, float32/64, complex64/128, string, []byte,
reflect.Value.
Fix — reflect.Type must be canonical (map-key ordering)
Go’s reflect.Type is a canonical interned descriptor: TypeOf(x) == TypeOf(y) exactly when x
and y share a dynamic type, so aType == bType is a pointer compare. internal/fmtsort.compare
(the map-key ordering used by fmt’s %v) relies on it: if aType != bType { return -1 }.
The bridge minted a fresh wrapper per access — abi.TypeOf allocates a new abi.Type box, and
both Value.Type() and toType then build a fresh rtypeжΔType (an IжAdapter compared by box
identity via golib AreEqual). So two Types describing the same type never compared equal →
compare returned -1 for every pair → the stable sort reversed the keys
(map[b:2 a:1] instead of map[a:1 b:2]).
Fix: hand-own Value.Type and toType in reflect/value_impl.cs (registered in
go2cs/manualTypeOperations.go manualConversionFuncs["reflect"]) so both route through canonType,
which interns the ΔType wrapper in a ConcurrentDictionary<System.Type, ΔType> keyed on the
abi.Type.sysType the Phase-1 synthType stamped. Identity-equality then matches Go. The cache is
process-lifetime (type descriptors are permanent, like Go’s). Interning by System.Type preserves
Go’s named-type distinctness for free: a type Celsius float64 is a distinct [GoType("num:float64")]
struct whose System.Type differs from float64, so TypeOf(Celsius) != TypeOf(float64). typeSlow
(method-value Types) stays auto — not exercised by fmt.
Fix — the bridge’s per-type marker reads are memoized (2026-07-26)
The bridge recovers Go type identity from managed metadata, so it reads the converter’s per-type
markers — [GoType] for a type’s kind and underlying, [GoLocalName] for a lifted local type’s
original Go name — from its hottest entry points: KindOf (under every ValueOf, Value.Field,
Value.Elem, MakeSlice, rtype.FieldByName, and abi.TypeOf), GoTypeName (under every %T and
reflect.Type.String()/Name()), and TryConvertTo/TryUnwrapWrapperValue (under the whole
Value.Set/SetMapIndex/Call/Convert marshalling surface). Custom-attribute retrieval
materializes fresh attribute instances on every call and none of those callers caches its own
result, so the cost was paid per VALUE rather than per type — measured at 165–558 ns and 72–448 bytes
per call across those entry points, of which the attribute read was the bulk. All four reads now route
through two per-type ConcurrentDictionary memos (goTypeMarkerOf / goLocalNameOf), which is
sound for the same reason canonType’s intern above is: type descriptors are permanent, and a loaded
type’s own attributes cannot change. Full measurement and the two remaining non-attribute residuals
are in DESIGN-iface-shell-caching.md §11.2, which is where the
audit that found them lives.
Fix — a type descriptor’s order token is its NAME, not its box identity (2026-08-10)
The section above made two reflect.Types for one Go type compare equal. This one is about what
happens next, when they compare unequal: internal/fmtsort.compare’s reflect.Interface arm
orders interface-kinded map keys by their dynamic types, and it does that by comparing the two type
descriptors as POINTERS —
c := compare(reflect.ValueOf(aVal.Elem().Type()), reflect.ValueOf(bVal.Elem().Type()))
reflect.ValueOf(aType) is a *reflect.rtype, so compare recurses into its reflect.Pointer arm
and returns cmp.Compare(aVal.Pointer(), bVal.Pointer()). That token is therefore not an internal
detail: it is the printed order of fmt.Println(map[I]int{…}).
Go answers with the descriptor’s machine address — the linker’s type-section layout. That ordering is
deliberately unspecified: fmtsort’s own TestInterface says “the relative ordering of types is
unspecified” and asserts only that same-type keys form contiguous sorted subgroups. It is also not a
function of anything the managed side can observe; measured against Go 1.23.1, one program lays
main.Mango before main.Zebra before main.Apple while another lays the same three names in
alphabetical order.
The managed bridge has no addresses, so reflect.Value.Pointer answers ж<T>.PointerOrderToken,
which for a descriptor box composes RuntimeHelpers.GetHashCode of the underlying abi.Type. That
is worse than unspecified. CoreCLR draws an object’s identity hash from a per-thread PRNG, so the
token is fixed for a given build but bears no relation to the type it describes, and the printed
order flips whenever an unrelated edit changes how many hashes are drawn before these two. That is a
coin flip re-tossed on every commit, and it landed tails: tests/Behavioral/InterfaceInheritance
(a map[I]int keyed by main.T1/main.T2, both rendering as "", so only the values reveal the
order) was graduated to output comparison green at c87a7f145 and printed map[:2 :1] against Go’s
map[:1 :2] by c7b297c87. No commit in that window broke it — bisecting names a bystander. The
demonstration is direct: adding a diagnostic that merely touches reflect BEFORE the Println moves
both tokens and restores the correct order.
Fix: reflectPointerToken (reflect/value_impl.cs) now routes a pointer to a type descriptor —
ж<rtype> or ж<abi.Type> — through typeDescriptorOrderToken, which packs the leading
IntPtr.Size bytes of the type’s Go name big-endian, so comparing tokens arithmetically compares the
names lexically. The name is the one Type.String() prints (GoReflect.GoTypeName over the
descriptor’s sysType + arrayDims), which gives the invariant worth stating: types that print
alike token alike, and types that print differently order by that printed name. Same-type grouping
is therefore exact, and the order is stable across builds, runs and unrelated edits — the one
property the address model cannot offer and the PRNG model actively destroys.
Two bounded residuals, both deliberate:
- Names agreeing over the whole packed prefix tie (8 bytes on a 64-bit host).
comparethen falls through tocompare(aVal.Elem(), bVal.Elem()), the same arm Go reaches for two keys of one type; for two different types that arm returns-1either way, so the pair’s relative order is settled byslices.SortStableFunc’s stability rather than by the comparison. Deterministic, which is the property being bought here. - Matching Go exactly is not on offer for three or more distinct key types, since Go’s order is
its linker’s.
InterfaceInheritanceagrees becausemain.T1precedesmain.T2both ways; a program whose layout order diverges from name order would not, and a behavioral test asserting one would be asserting something Go does not promise.
Only two call sites in the corpus read reflect.Value.Pointer: internal/fmtsort (this ordering,
validated 3/3 including TestInterface’s grouping assertion) and
vendor/golang.org/x/crypto/internal/alias, which compares slice-element pointers and never reaches
the descriptor branch.