@@ -107,15 +107,18 @@ pub fn canonical_concept_domain(id: u16) -> ConceptDomain {
107107}
108108
109109/// Resolve a [`NodeGuid`](crate::NodeGuid) `classid` to its [`ConceptDomain`] (D-OVC-4). The
110- /// codebook id is the low 16 bits of the classid (`0xDDCC` lives in the low u16);
111- /// the high u16 is the canon-reserved zero-fallback prefix. So a domain route is
112- /// `canonical_concept_domain(classid as u16)`. This is the coarse sibling of the
110+ /// codebook id is the CANON half of the classid (under the active
111+ /// [`CLASSID_ORDER`] — the low u16 while the order is `CanonLow`); the other
112+ /// half is the custom/render prefix. So a domain route is
113+ /// `canonical_concept_domain(classid_canon(classid))`. This is the coarse sibling of the
113114/// per-family scope in [`codebook`](crate::codebook): classid (domain) selects the
114115/// coarse codebook; `family` selects the sub-codebook (longest-prefix-wins).
115116#[ inline]
116117#[ must_use]
117118pub fn classid_concept_domain ( classid : u32 ) -> ConceptDomain {
118- canonical_concept_domain ( classid as u16 )
119+ // Routes the CANON half via the one flippable split (D-CCF-0) — identical
120+ // to the historical `classid as u16` while CLASSID_ORDER is CanonLow.
121+ canonical_concept_domain ( classid_canon ( classid) )
119122}
120123
121124/// Map a coarse curator `source_domain` tag (`"project"`, `"erp"`, `"german-erp"`)
@@ -233,7 +236,12 @@ impl AppPrefix {
233236#[ inline]
234237#[ must_use]
235238pub const fn render_classid ( prefix : u16 , concept : u16 ) -> u32 {
236- ( ( prefix as u32 ) << 16 ) | ( concept as u32 )
239+ // The prefix is the CUSTOM half, the concept the CANON half — composed
240+ // through the one flippable definition (D-CCF-0): identical to the
241+ // historical `(prefix << 16) | concept` while CLASSID_ORDER is CanonLow.
242+ // The OGAR#95 hi-u16 scheme ↔ CanonHigh reconciliation is the plan's P2
243+ // operator checkpoint; this route-through is what makes it one-place.
244+ compose_classid ( concept, prefix)
237245}
238246
239247/// Compose a render `classid` from an [`AppPrefix`] and a **canonical-concept
@@ -254,23 +262,121 @@ pub fn render_classid_for_concept(app: AppPrefix, concept: &str) -> Option<u32>
254262 canonical_concept_id ( concept) . map ( |id| app. render ( id) )
255263}
256264
257- /// The APP / render-prefix half of a full `classid` (`classid >> 16`). Mirror
258- /// of OGAR `ogar_vocab::app::app_of`. Pair with [`AppPrefix::from_prefix`] to
259- /// recover the typed app.
265+ // ═══════════════════════════════════════════════════════════════════════════
266+ // The ONE flippable classid composition (D-CCF-0, `classid-canon-custom-flip-v1`)
267+ // ═══════════════════════════════════════════════════════════════════════════
268+
269+ /// Which u16 half of a stored `classid: u32` carries the CANON (`domain:appid`
270+ /// / concept) and which carries the CUSTOM (render prefix / the temporary
271+ /// `0x1000` V3 marker). This is the operator's "split order that later you can
272+ /// flip" made a type (`E-CLASSID-SPLIT-ORDER-IS-A-FLIP`): the Canon:Custom
273+ /// half-order migration (`.claude/plans/classid-canon-custom-flip-v1.md`,
274+ /// TRIGGERED 2026-07-02) is a one-place change of [`CLASSID_ORDER`], never
275+ /// per-site byte surgery.
276+ #[ derive( Debug , Clone , Copy , PartialEq , Eq , Hash ) ]
277+ pub enum ClassidOrder {
278+ /// Legacy order: canon in the LOW u16, custom in the HIGH (the `0xDDCC`
279+ /// low-half convention every wired classid uses today).
280+ CanonLow ,
281+ /// Target order: canon in the HIGH u16, custom in the LOW — stored
282+ /// `0x0701_1000`, human-readable `0x07:01::1000` (plan §0).
283+ CanonHigh ,
284+ }
285+
286+ /// The active half-order. **P0 pins the legacy order** — every route-through
287+ /// below is behavior-identical to the direct masks it replaces (probed).
288+ /// Flipping this const to [`CanonHigh`](ClassidOrder::CanonHigh) IS Phase 1 of
289+ /// the migration; it is mint-forward with a version boundary — flipping alone
290+ /// must never reinterpret persisted ids (the registry keeps concrete-keyed
291+ /// legacy aliases; plan §4 P3, codex P2 on #627).
292+ pub const CLASSID_ORDER : ClassidOrder = ClassidOrder :: CanonLow ;
293+
294+ /// Compose a classid under an explicit half-order.
295+ #[ inline]
296+ #[ must_use]
297+ pub const fn compose_classid_with ( order : ClassidOrder , canon : u16 , custom : u16 ) -> u32 {
298+ match order {
299+ ClassidOrder :: CanonLow => ( ( custom as u32 ) << 16 ) | ( canon as u32 ) ,
300+ ClassidOrder :: CanonHigh => ( ( canon as u32 ) << 16 ) | ( custom as u32 ) ,
301+ }
302+ }
303+
304+ /// Split a classid under an explicit half-order → `(canon, custom)`.
305+ #[ inline]
306+ #[ must_use]
307+ pub const fn split_classid_with ( order : ClassidOrder , classid : u32 ) -> ( u16 , u16 ) {
308+ match order {
309+ ClassidOrder :: CanonLow => ( classid as u16 , ( classid >> 16 ) as u16 ) ,
310+ ClassidOrder :: CanonHigh => ( ( classid >> 16 ) as u16 , classid as u16 ) ,
311+ }
312+ }
313+
314+ /// Compose under the active [`CLASSID_ORDER`].
315+ #[ inline]
316+ #[ must_use]
317+ pub const fn compose_classid ( canon : u16 , custom : u16 ) -> u32 {
318+ compose_classid_with ( CLASSID_ORDER , canon, custom)
319+ }
320+
321+ /// Split under the active [`CLASSID_ORDER`] → `(canon, custom)`.
322+ #[ inline]
323+ #[ must_use]
324+ pub const fn split_classid ( classid : u32 ) -> ( u16 , u16 ) {
325+ split_classid_with ( CLASSID_ORDER , classid)
326+ }
327+
328+ /// The CANON half under the active order — **the** source of the SoA
329+ /// `class_id`/`EntityType` discriminator. Post-flip, a naive `classid as u16`
330+ /// yields the CUSTOM half (`0x1000`) for every V3 class — total class
331+ /// collapse (codex P2 on #627) — so deriving a class discriminator any other
332+ /// way is a forbidden pattern.
333+ #[ inline]
334+ #[ must_use]
335+ pub const fn classid_canon ( classid : u32 ) -> u16 {
336+ split_classid ( classid) . 0
337+ }
338+
339+ /// The CUSTOM half under the active order (render prefix / marker).
340+ #[ inline]
341+ #[ must_use]
342+ pub const fn classid_custom ( classid : u32 ) -> u16 {
343+ split_classid ( classid) . 1
344+ }
345+
346+ /// Recompose a classid under the OTHER order — the flip itself. Involutive:
347+ /// `flip_classid(flip_classid(x)) == x` (probed below).
348+ #[ inline]
349+ #[ must_use]
350+ pub const fn flip_classid ( classid : u32 ) -> u32 {
351+ let ( canon, custom) = split_classid ( classid) ;
352+ let other = match CLASSID_ORDER {
353+ ClassidOrder :: CanonLow => ClassidOrder :: CanonHigh ,
354+ ClassidOrder :: CanonHigh => ClassidOrder :: CanonLow ,
355+ } ;
356+ compose_classid_with ( other, canon, custom)
357+ }
358+
359+ /// The APP / render-prefix half of a full `classid` — since #627, the CUSTOM
360+ /// half under the active [`CLASSID_ORDER`] (identical to the historical
361+ /// `classid >> 16` while the order is [`CanonLow`](ClassidOrder::CanonLow)).
362+ /// Mirror of OGAR `ogar_vocab::app::app_of`. Pair with
363+ /// [`AppPrefix::from_prefix`] to recover the typed app.
260364#[ inline]
261365#[ must_use]
262366pub const fn classid_app_prefix ( classid : u32 ) -> u16 {
263- ( classid >> 16 ) as u16
367+ classid_custom ( classid)
264368}
265369
266- /// The canonical concept-id half of a full `classid` (`classid as u16`) — the
267- /// shared RBAC + ontology + cross-app identity key, identical under every
370+ /// The canonical concept-id half of a full `classid` — since #627, the CANON
371+ /// half under the active [`CLASSID_ORDER`] (identical to the historical
372+ /// `classid as u16` while the order is [`CanonLow`](ClassidOrder::CanonLow)) —
373+ /// the shared RBAC + ontology + cross-app identity key, identical under every
268374/// render prefix. Mirror of OGAR `ogar_vocab::app::concept_of`; the sibling of
269375/// [`classid_concept_domain`], which routes this half to its [`ConceptDomain`].
270376#[ inline]
271377#[ must_use]
272378pub const fn classid_concept ( classid : u32 ) -> u16 {
273- classid as u16
379+ classid_canon ( classid)
274380}
275381
276382/// The curated `(canonical_concept, u16)` codebook — wire-compatible mirror of
@@ -620,4 +726,105 @@ mod tests {
620726 None
621727 ) ;
622728 }
729+
730+ // ── D-CCF-0 probes — the one flippable classid composition ────────────
731+
732+ #[ test]
733+ fn classid_split_compose_round_trips_under_both_orders ( ) {
734+ let samples: & [ ( u16 , u16 ) ] = & [
735+ ( 0x0700 , 0x0000 ) , // legacy OSINT domain classid halves
736+ ( 0x0701 , 0x1000 ) , // post-flip OSINT:q2 halves
737+ ( 0x0A01 , 0x1000 ) ,
738+ ( 0x0E01 , 0x1000 ) ,
739+ ( 0x0901 , 0x0005 ) , // Healthcare render pair
740+ ( 0x0000 , 0x0000 ) ,
741+ ( 0xFFFF , 0xFFFF ) ,
742+ ] ;
743+ for & ( canon, custom) in samples {
744+ for order in [ ClassidOrder :: CanonLow , ClassidOrder :: CanonHigh ] {
745+ let id = compose_classid_with ( order, canon, custom) ;
746+ assert_eq ! (
747+ split_classid_with( order, id) ,
748+ ( canon, custom) ,
749+ "split∘compose must be identity under {order:?}"
750+ ) ;
751+ }
752+ }
753+ }
754+
755+ #[ test]
756+ fn classid_flip_is_involutive_and_p0_pins_legacy_order ( ) {
757+ // P0 pin: the active order is the legacy CanonLow — flipping this
758+ // const IS the migration's Phase 1, never a drive-by.
759+ assert_eq ! ( CLASSID_ORDER , ClassidOrder :: CanonLow ) ;
760+ // flip(flip(x)) == x over every wired classid + the post-flip trio.
761+ for id in [
762+ 0x0000_0700u32 , // legacy OSINT domain class
763+ 0x1000_0700 , // pre-flip OSINT-V3
764+ 0x1000_0A01 ,
765+ 0x1000_0E00 ,
766+ 0x0701_1000 , // post-flip forms (already valid u32s to flip back)
767+ 0x0A01_1000 ,
768+ 0x0E01_1000 ,
769+ 0x0005_0901 , // Healthcare render classid
770+ 0x0000_0000 ,
771+ 0xFFFF_FFFF ,
772+ ] {
773+ assert_eq ! (
774+ flip_classid( flip_classid( id) ) ,
775+ id,
776+ "flip must be involutive"
777+ ) ;
778+ }
779+ }
780+
781+ #[ test]
782+ fn classid_route_through_is_behavior_identical_under_legacy_order ( ) {
783+ // The legacy-boundary matrix (plan §3): under CanonLow every routed
784+ // reader equals the direct mask it replaced, for every codebook id
785+ // under every app prefix — the P0 zero-behavior gate.
786+ for & ( _, concept) in CODEBOOK {
787+ for prefix in [ 0x0000u16 , 0x0001 , 0x0005 , 0x1000 ] {
788+ let id = render_classid ( prefix, concept) ;
789+ assert_eq ! ( id, ( ( prefix as u32 ) << 16 ) | ( concept as u32 ) ) ;
790+ assert_eq ! ( classid_concept( id) , concept) ;
791+ assert_eq ! ( classid_app_prefix( id) , prefix) ;
792+ assert_eq ! ( classid_canon( id) , id as u16 ) ;
793+ assert_eq ! ( classid_custom( id) , ( id >> 16 ) as u16 ) ;
794+ assert_eq ! (
795+ classid_concept_domain( id) ,
796+ canonical_concept_domain( concept) ,
797+ "domain routing invariant under the route-through"
798+ ) ;
799+ }
800+ }
801+ }
802+
803+ #[ test]
804+ fn no_class_collapse_under_canon_high ( ) {
805+ // codex P2 (#627): post-flip, a naive `as u16` reads the CUSTOM half —
806+ // 0x1000 for ALL three V3 classes — collapsing the SoA class_id
807+ // discriminator. The canon half stays distinct; `as u16` does not.
808+ let osint = compose_classid_with ( ClassidOrder :: CanonHigh , 0x0701 , 0x1000 ) ;
809+ let fma = compose_classid_with ( ClassidOrder :: CanonHigh , 0x0A01 , 0x1000 ) ;
810+ let cpic = compose_classid_with ( ClassidOrder :: CanonHigh , 0x0E01 , 0x1000 ) ;
811+ assert_eq ! ( ( osint, fma, cpic) , ( 0x0701_1000 , 0x0A01_1000 , 0x0E01_1000 ) ) ;
812+
813+ let canons = [
814+ split_classid_with ( ClassidOrder :: CanonHigh , osint) . 0 ,
815+ split_classid_with ( ClassidOrder :: CanonHigh , fma) . 0 ,
816+ split_classid_with ( ClassidOrder :: CanonHigh , cpic) . 0 ,
817+ ] ;
818+ assert_eq ! (
819+ canons,
820+ [ 0x0701 , 0x0A01 , 0x0E01 ] ,
821+ "canon halves stay distinct"
822+ ) ;
823+ // The forbidden pattern, demonstrated: `as u16` collapses all three.
824+ assert_eq ! (
825+ [ osint as u16 , fma as u16 , cpic as u16 ] ,
826+ [ 0x1000 , 0x1000 , 0x1000 ] ,
827+ "naive `as u16` post-flip = total class collapse (why it is forbidden)"
828+ ) ;
829+ }
623830}
0 commit comments