diff --git a/docs/APP-CLASS-CODEBOOK-LAYOUT.md b/docs/APP-CLASS-CODEBOOK-LAYOUT.md new file mode 100644 index 0000000..2a17036 --- /dev/null +++ b/docs/APP-CLASS-CODEBOOK-LAYOUT.md @@ -0,0 +1,495 @@ +# APP‖CLASS CODEBOOK LAYOUT — the full classid u32 + +> **What this resolves:** the operator's observation that the GUID's +> `classid` is **8 hex (u32)** but the codebook has only ever used the +> **low 4 hex (u16)**. The high u16 was *reserved zero*, not used for +> SoA versioning (that is `ENVELOPE_LAYOUT_VERSION: u8`, a separate +> byte — `lance-graph-contract/src/soa_envelope.rs:54`). This doc +> claims the high u16 as the **APP / codebook-namespace + render +> prefix** and pins the rule that keeps "classid is shared currency" +> intact. +> +> **The goal it serves (§3.5–3.7):** every renderable thing — strings, +> text, media, online sources — is rendered by **key-value resolution** +> against typed content stores, so **no serialization exists in the hot +> path** (the Firewall, ADR-022/023). The high u16 selects *which app's* +> Askama template / `ClassView` renders an object; the low u16 is the +> shared concept (RBAC + ontology); each field within is itself a key +> into a content store. Render = address resolution, never parse. The +> **same discipline extends to RAG** (§3.7): retrieval over the graph +> (rs-graph-llm) moves **keys**; content materializes into the LLM only +> at the membrane, exactly once. **Two membranes (UI render + LLM +> prompt), one rule — the hot path stays blob-free end to end.** +> +> Status: **SPEC** (codebook minting is mechanical from here, gated on +> the 5+3 pass + `PROBE-OGAR-RBAC-AUTHORIZE` for the auth arm). +> Append-only. Cross-refs: `CLASSID-RBAC-KEYSTONE-SPEC.md`, +> `CONSUMER-MIGRATION-HOWTO.md`, OGAR `CLAUDE.md` § "Codebook scoping +> = the class routing prefix", lance-graph CANON § "Minimal SoA node". + +--- + +## 0. The layout (counted in hex, per the canon) + +``` +classid : u32 = [ hi u16 : APP / codebook namespace ] [ lo u16 : in-codebook class ] + 0xAAAA 0xDDCC + ^ which codebook (0x0000 = shared core) ^ domain DD | concept CC +``` + +- **`hi u16` (0xAAAA) — APP prefix = codebook namespace selector.** + Which 256⁶ semantic space / which centroid-codebook set the key + resolves against (the longest-prefix codebook scoping already pinned + in OGAR `CLAUDE.md`). `0x0000` is the **shared canonical core** — the + cross-app ontology every consumer reuses. A non-zero value is an + **app-private codebook**. +- **`lo u16` (0xDDCC) — in-codebook class id.** Domain byte `DD` + + concept byte `CC`, exactly as the codebook encodes today. Within the + core codebook (`hi = 0x0000`) the domain bytes are the canonical map + (`0x01` project, `0x02` commerce, `0x07` osint, `0x08` ocr, `0x09` + health). Within an app-private codebook the app owns its own `DD|CC` + layout. + +**This is additive, not a reclaim.** Every classid shipped to date is +`0x0000_DDCC` — i.e. it was *always* an APP‖class id with `APP = core`. +Nothing re-numbers. The canon's "RESERVE, DON'T RECLAIM" holds exactly: +`hi = 0x0000` is the bootstrap/core prefix; minting a non-zero `hi` +wakes app-private codebook routing with **zero `ENVELOPE_LAYOUT_VERSION` +change**, because the classid keeps its fixed 4-byte offset at key bytes +`0..4`. + +--- + +## 1. The two halves carry two orthogonal things + +An object's key holds **one** classid, yet two facts must travel with +it: *what it means* (shared, for RBAC + ontology + cross-app reasoning) +and *how this app renders it* (per-app — which Askama template + field +layout). The two halves of the u32 carry exactly those, orthogonally: + +| half | answers | keyed by | shared? | +|---|---|---|---| +| **lo u16 `0xDDCC`** | **WHAT it is** — canonical concept + domain | RBAC grant lattice, ontology enrichment, cross-app identity | **shared** across all apps | +| **hi u16 `0xAAAA`** | **WHOSE rendering** — app `ClassView` / Askama template / SoA layout | object render + skeleton-layout | **per-app** | + +So Medcare's patient is **`0x0005_0901`**: the low half `0x0901` shares +the `patient` grant lattice and OGIT ontology with *every* health app; +the high half `0x0005` binds it to **Medcare's** clinical template. +`0x0000_0901` is the canonical/abstract anchor (the master concept + +default `ClassView`). This is the canon's "the key prerenders nodes with +zero value decode" made literal — **both halves come straight from the +key**: high picks the template, low picks the concept/domain, no value +decode (see §3.5). + +**Consequence: the high u16 is the NORM for every rendered object, not +an escape hatch.** Every app stamps its own prefix so its surface +objects bind to its own templates, *while still pulling a shared low-u16 +concept* so RBAC and ontology stay cross-app. The unit of currency — the +**shared meaning** — is the low half; the high half is the render lens. + +> **Rule: low half = pull a CORE concept (shared identity). High half = +> stamp YOUR app prefix (your render binding).** An object that has a +> canonical analogue uses `your_app ‖ core_concept`. An object with **no** +> canonical analogue uses `your_app ‖ app_local_concept` — the genuine +> escape hatch, where the low half is also app-minted. + +| | low half = CORE concept (`0xDDCC` shared) | low half = APP-LOCAL concept (`0xDDCC` app-minted) | +|---|---|---| +| **When** | the object means something canonical (patient, invoice, project) | the object has **no** canonical analogue | +| **classid** | `your_app ‖ 0x0901` e.g. `0x0005_0901` | `your_app ‖ 0xFF01` e.g. `0x0005_FF01` | +| **RBAC/ontology** | shared lattice via low half | app-private lattice | +| **Rendering** | app template via high half | app template via high half | +| **Frequency** | the **norm** | the **exception** | + +The "codebook per project" win the operator named is the high half: +each app prefix roots **its own** centroid-codebook hierarchy and its +own ClassView/template set, so per-app rendering scales **without +radix-trie codebook overflow** — the shared core never has to hold one +template-variant per app. + +**Promotion path (app-local → core):** if an app-local *concept* (low +half) turns out reusable, promote it into a core domain block via the +5+3 codebook gate and leave the app-local id as a deprecated alias. +ClassViews/templates never promote (they are app-private by nature). +Demotion never happens (RESERVE, DON'T RECLAIM). + +--- + +## 2. APP-prefix allocation table (reserved; non-zero wakes a private codebook) + +`hi = 0x0000` is core. Non-zero prefixes are **reserved by app** so two +apps never collide. Reserving a prefix costs nothing (no codebook is +materialised until the app mints its first private class). + +| `hi u16` | App / namespace | Core domain(s) it consumes | Private codebook today? | +|---|---|---|---| +| `0x0000` | **Shared canonical core** | all (`0x01/02/07/08/09` + `0x0B` auth) | n/a (this *is* core) | +| `0x0001` | OpenProject (openproject-nexgen-rs) | `0x01` project-mgmt | **no** — maps onto core | +| `0x0002` | Odoo | `0x02` commerce | **no** — maps onto core (converge `od-ontology`) | +| `0x0003` | WoA / woa-rs | `0x02` commerce (work orders) | **no** — maps onto core | +| `0x0004` | SMB-Office / smb-office-rs | `0x02` commerce | **no** — maps onto core | +| `0x0005` | **Medcare / medcare-rs** | `0x09` health | **escape hatch only** (see §3) | +| `0x0006` | q2 (Gotham / aiwar / neo4j) | `0x07` osint (+ TBD) | **TBD** — port not yet authored | +| `0x0007` | Redmine | `0x01` project-mgmt | **no** — same concepts as OpenProject, own templates | +| `0x00A0` | (reserved) future app block | — | — | + +> **OpenProject (`0x0001`) and Redmine (`0x0007`) are the showcase:** +> same low-u16 concepts (`WorkPackage`/`Issue` both → `0x0102` +> project_work_item; same RBAC `project_role 0x0117` lattice), different +> high-u16 render prefix (different ClassView/Askama template). Two +> renders, one concept — the cleanest demonstration of §1. See +> `APP-CODEBOOK-MIGRATION-PLAN.md` W0. + +Auth is **not** its own APP — auth providers are canonical, cross-app +profiles, so they live in **core** under a new `0x0B` auth domain +(`auth_store = 0x0000_0B01`, `auth_zitadel`/`auth_zanzibar`/ +`auth_ory_keto` as provider profiles). See +`CLASSID-RBAC-KEYSTONE-SPEC.md` §7 — those classes mint into core, not +under any app prefix. (This corrects the earlier "flat 0x011B" mint +attempt: auth classes are core-domain `0x0B`, not project-domain +`0x01`.) + +--- + +## 3. MEDCARE — the worked APP‖class layout + +Medcare is one **consumer of the canonical Health domain**, not the +owner of clinical ontology. So: + +### 3a. Concept low half — PULLED from core (domain `0x09`, shared) + +The 7 canonical OGIT Healthcare concepts — already shipped, shared with +any future health consumer. These are the **low-u16 concept anchors**; +their `hi = 0x0000` form is the abstract master + default ClassView: + +| Concept | core anchor (u32) | low half (shared) | Source | +|---|---|---|---| +| patient | `0x0000_0901` | `0x0901` | OGIT `Healthcare:Patient` | +| diagnosis | `0x0000_0902` | `0x0902` | OGIT `Healthcare:Diagnosis` | +| lab_value | `0x0000_0903` | `0x0903` | OGIT `Healthcare:LabValue` | +| medication | `0x0000_0904` | `0x0904` | OGIT `Healthcare:Medication` | +| treatment | `0x0000_0905` | `0x0905` | OGIT `Healthcare:Treatment` | +| visit | `0x0000_0906` | `0x0906` | OGIT `Healthcare:Visit` | +| vital_sign | `0x0000_0907` | `0x0907` | OGIT `Healthcare:VitalSign` | + +`HealthcarePort` resolves Medcare's surface names (`Patient`, +`Befund`→`diagnosis`, `Laborwert`→`lab_value`, …) onto these **low +halves**. RBAC + ontology key on the low half, so the grant lattice is +shared with every health consumer. **No medcare bridge.** + +### 3b. Render high half — STAMPED as `0x0005` (Medcare's ClassView) + +Medcare's *rendered* objects carry its own prefix in the high half. Same +shared concept, Medcare's template + SoA layout: + +| Medcare object | classid (u32) | low (shared concept) | high (Medcare render) | +|---|---|---|---| +| Medcare patient view | `0x0005_0901` | `0x0901` patient | Medcare `patient.html` Askama template | +| Medcare diagnosis (Befund) | `0x0005_0902` | `0x0902` diagnosis | Medcare `befund.html`, PII leaf-rename at adapter | +| Medcare lab value (Laborwert) | `0x0005_0903` | `0x0903` lab_value | Medcare `laborwert.html` | + +- **Authorize** keys on `classid as u16` → `0x0901` → shared patient + grant: `authorize(actor, 0x0005_0901 as u16, Read)`. +- **Render** keys on the full u32 → `0x0005_0901` → Medcare ClassView → + Askama template + field order (§3.5). +- The PII leaf-rename (German clinical labels never leave the membrane) + is the Medcare ClassView's job — bound by the high half, exactly where + it should be. + +### 3c. Genuinely-bespoke Medcare objects (low half ALSO app-minted) + +Only entities with **no** canonical analogue — low half is Medcare's to +mint too: + +``` +0x0005_F0CC medcare bespoke object classes (no canonical analogue) +0x0005_FFCC medcare local special-cases (the long tail CLAUDE.md §3 names) +``` + +| Candidate | Provisional classid | Why fully app-private | +|---|---|---| +| medcare insurance-case (German GKV/PKV billing quirk) | `0x0005_F001` | clinic-billing specific; no canonical analogue yet | +| medcare KIM/TI-message envelope | `0x0005_F002` | German telematics-infra specific | +| medcare migration import row (`/api/admin/migration/sql/import`) | `0x0005_FF01` | the one MySQL-only-by-design route (medcare-rs CLAUDE.md) | + +**If a concept has a canonical analogue, use it in the low half** (§3b) +— do not fork a new low-u16 id just to render it. The fully-private form +(§3c) is for concepts that genuinely don't generalize. + +### 3d. Medcare's diff, concretely + +1. **OGAR** (one PR, gated): reserve `0x0005` for Medcare; confirm + `HealthcarePort` maps the 7 core concept **low halves** (it does — + `ports::HealthcarePort`); register Medcare's ClassViews/templates + under `0x0005`. Mint fully-private (§3c) classes only when a real + bespoke entity needs one (none required to ship the patient-read + gate). +2. **medcare-rs** (its own crate): pull the concept low half statically + (`HealthcarePort::class_id("Patient") == Some(0x0901)`); form its + render classid `0x0005_0000 | 0x0901 = 0x0005_0901`; enrich (RLS / + masks) and render by that classid; authorize by the **low half**. The + spine (`lance-graph-ogar`, `lance-graph-rbac`) is byte-for-byte + unchanged. + +The in-flight medcare patient-read gate (PR #169) is the first +consumer: today it keys on `static_role`; once the keystone + this +layout land, it keys on `authorize(actor, 0x0005_0901 as u16, Read)` +(= the shared `0x0901` patient grant) and renders via the `0x0005` +Medcare ClassView. + +--- + +## 3.5. Rendering — the high u16 IS the Askama / ClassView binding + +This is *why* the high u16 is the norm, not an escape hatch. **Object +rendering is per-app**, and the render binding must come straight from +the key (canon: "the key prerenders nodes with zero value decode"). The +high u16 is that binding: + +``` +object key ──► classid (u32) ──► ClassView lookup keyed on FULL u32 + │ │ + │ ├─ Askama template handle (which .html) + │ ├─ SoA field order / column projection + │ └─ label set (PII leaf-rename at adapter) + │ + └─ low u16 ─► concept/domain ─► RBAC grant + ontology (shared) +``` + +- **One concept, many renders.** `0x0000_0901` (canonical patient), + `0x0005_0901` (Medcare's patient form), and a hypothetical + `0x0007_0901` (another health app's patient card) are the **same + concept** (low half `0x0901` — same grant, same ontology) rendered + three ways (three ClassViews / three Askama templates). The high half + selects the template **without** decoding the row. +- **`ClassView` is already the render manifest.** The canon binds + `classid → ClassView`; the ClassView carries the structural signature + (field set, order) the template iterates. Stamping the high u16 means + "use *this app's* ClassView for this concept" — the Askama template is + a property of that ClassView, resolved by the same `resolve` read that + resolves schema and codebook. +- **Why not render off the low half alone?** Because then every app + would have to share one template per concept, or fork the concept id + to get a distinct template — the radix-trie overflow the operator + flagged. Splitting render (high) from meaning (low) lets an unbounded + number of apps each render `patient` their own way while the `patient` + grant lattice and ontology stay singular and shared. +- **Skeleton render with zero value decode.** A list/grid/planner view + can lay out N objects — pick each one's template, group by app, order + fields — from the keys alone, before fetching any value bytes. That is + the canon's KEY-IS-KEY-OF-KEY-VALUE promise applied to the UI layer. + +**Consumer pattern (woa-rs / smb / medcare with Askama):** + +```rust +// at the boundary: object's full classid is in its key +let concept = (classid as u16); // 0x0901 — shared: RBAC + ontology +let render = ClassView::resolve(classid); // full u32 — this app's template +let html = render.template().render(&row)?; // Askama, compile-time checked +authorize(actor, concept, Op::Read)?; // grant lattice on the shared low half +``` + +Stack note: woa-rs and smb-office-rs already use **Askama** (compile-time +checked templates — woa-rs CLAUDE.md stack table). The render classid is +the key that selects *which* compiled template; nothing about the +template engine changes — the classid just replaces ad-hoc +`match entity_kind { … }` template dispatch with a key-driven ClassView +lookup. + +--- + +## 3.6. THE GOAL — content is key-value too, so NO serialization in the hot path + +The render binding (§3.5) is only half the win. The other half is that +**every renderable field — string, text, media, online source — is +itself a key-value entry**, resolved by address, never serialized into +the row and deserialized to render. This is the Firewall (ADR-022/023: +*no serialization in the hot path*) made structural: there is nothing to +serialize, because content was never inlined — it is always a key that +resolves to bytes already sitting in their backing store. + +This is the **registry axiom** (`CLASSID-RBAC-KEYSTONE-SPEC.md` I-K0 — +label = KEY, meaning = VALUE) applied to *content*: + +| Field kind | Inline blob (FORBIDDEN — serializes) | Key-value entry (CANON) | +|---|---|---| +| **String** | the bytes in the row, serde-encoded | a dictionary/palette **key** → interned string table (Lance dictionary column); render = O(1) lookup | +| **Text** | the paragraph serialized into the value | a `text` content **classid ‖ identity** → Lance column; render = zero-copy mmap slice | +| **Media** (image/audio/blob) | base64 in JSON | a `media` content **key** → bytes in Lance / object-store URI resolved from the key; render emits a reference, bytes stream zero-copy | +| **Online source** (URL/remote) | the fetched body serialized into the row | a `source` **key** → URI registry entry; render resolves key → canonical URI (+ cache), the remote body is never serialized into the object | + +So a rendered object is a **tree of keys**: the object's classid (high = +app template, low = concept) selects the Askama template; each field the +template iterates is itself a key that resolves — by the same key-value +lookup — into a typed content store. The whole render path, top to leaf, +is **address resolution**, not parsing. + +``` +object classid (u32) ─► ClassView ─► Askama template + for each field: + field key ─► content store (string dict / text col / media / source registry) + └─ resolve = columnar / dictionary lookup, LE bytes in place + ── no serde::Deserialize anywhere on this path ── +``` + +Why this is exactly the canon: + +- **"The key prerenders nodes with zero value decode."** The render + walks keys; value bytes are touched only as the final zero-copy slice + the template emits — never decoded into an intermediate struct. +- **"Lance is free to compress the value bits arbitrarily... the store + still has a transparent view and address."** Content stores compress + (dictionary, PQ, palette) freely; render still addresses by key + because the key is never compressed. +- **"Every SoA envelope is zero-copy from creation to Lance tombstone; + Lance's own columnar I/O writes LE bytes from the in-place backing + store."** Rendering reads those same LE bytes in place. Nothing is + serialized *to be rendered*. + +**Litmus for any render path:** if rendering a field requires a +`serde::Deserialize` / `serde_json::from_*` / a parse step, the field +was inlined as a blob — that is the Firewall violation. The fix is to +make the field a **key** into a content store and resolve it, not parse +it. Strings/text/media/sources are CAM/registry entries, never inline +serialized payloads. (Build-time codegen that *generates* the ClassView +from a manifest is fine — that is "compile types", not hot-path serde; +cf. medcare-rs CLAUDE.md §7.) + +--- + +## 3.7. RAG / LLM is the SAME egress discipline — key pointer in, content at the membrane + +UI render (§3.5) and RAG-to-LLM are the **same pattern with two +membranes**. Retrieval-augmented generation over the graph +(rs-graph-llm `graph-flow` driving lance-graph retrieval) keeps the hot +path blob-free by moving **keys**, and materializes content **only at +the LLM membrane**, exactly once: + +``` +hot path (BLOB-FREE — pointers only): + query ─► CAM-PQ / palette / Hamming search on fingerprints + ─► ranked classid ‖ identity KEYS (pointers, not content) + ─► graph walk / dedup / rank / assemble ── all on keys ── + ─► context = a LIST OF KEYS (a pointer set, never a concatenated blob) + +membrane (the ONE egress point — content materializes here, once): + for each key in context: + key ─► content store (§3.6: string dict / text col / media / source URI) + ─► content REFERENCE lands in the LLM prompt + └─ this is the only place a key becomes tokens ─┘ +``` + +- **Retrieval returns pointers.** Search (CAM-PQ compressed NN, palette + distance, Hamming) operates on fingerprints/keys and yields ranked + `classid ‖ identity` keys. Nothing is decompressed or deserialized to + rank — the canon's "compare without decompressing" (`codec.distance`). +- **Context is a key set, not a blob.** The assembled RAG context is a + list of pointers. Graph traversal, dedup, and ranking all move keys. + No serde, no concatenated text buffer, in the hot path. +- **Content lands in the LLM only at the membrane.** Each retrieved key + resolves to its content reference (§3.6) at the boundary to the LLM + call — the same "boundary parsed once" the Firewall mandates, and the + same MarkovBarrier the cognition stack already uses (crewai-rust + blood-brain-barrier: inner cognition on keys/fingerprints, content + materialized only at the external API edge). The high u16 still picks + *which app's* view/template a key materializes through, so RAG + citations render in the asking app's voice. +- **Two membranes, one rule.** UI render and LLM prompt are both egress + points where a key resolves to content exactly once. Everything + *behind* either membrane — storage, retrieval, ranking, assembly — is + pointer movement. **The hot path stays blob-free end to end.** + +**Litmus (RAG):** if context assembly builds a `String`/`Vec` of +materialized content before the LLM call, the blob entered the hot path +too early. Assemble a `Vec`; resolve at the membrane. (The LLM +*does* receive materialized text — that is correct; the point is it +happens once, at the edge, not threaded through retrieval.) + +--- + +## 3.8. Intuition — this is C64 / 6502 addressing + +The model is, deliberately, **8-bit-machine assembler**. It is worth +holding the analogy because every piece maps and the mapping is exact: + +| 6502 / C64 / VIC-II | this layout | +|---|---| +| 16-bit address as **page : offset** (zero-page indirect) | `classid` as **hi u16 : lo u16** (codebook/app page : concept offset) | +| **Character ROM** ($D000): char code → 8-byte glyph | **string/glyph codebook**: a key → interned bytes (§3.6 string row) | +| **Screen RAM** byte → VIC-II reads glyph, zero decode, every frame | object field key → ClassView resolves content, zero decode, every render | +| **Sprite pointers** ($07F8–$07FF): 1 byte → 64-byte sprite block | **media key** → media bytes / URI (§3.6 media row) | +| **Jump table** indexed by opcode/class | `classid → ClassView` → Askama template handle (§3.5) | +| **PEEK/POKE** — move addresses, content sits at fixed locations | move keys; content lives in its store; resolve by address | +| **No serialization exists** — there is no serde on a 6502 | no serde on the hot path (§3.6); content is addressed, not parsed | + +The VIC-II rendering a frame — read a screen byte, index character ROM, +emit the glyph, 50 times a second — *is* the §3.6 doctrine in silicon: a +key→value lookup table render with zero serialization in the hottest +path a machine has. We are rebuilding that discipline on Lance columns +and a 32-bit classid instead of $0400 screen RAM and a 16-bit address. +The "modern app" habit it rejects is the serde blob: a C64 never +deserialized a sprite, and neither should the hot path. + +--- + +## 4. Routing consequence (one function, longest-prefix-wins) + +`classid_concept_domain` today reads only the low u16 +(`canonical_concept_domain(classid as u16)` — +`lance-graph-contract/src/ogar_codebook.rs:81`). Under APP‖class that +becomes a two-step longest-prefix bind, and it stays O(1): + +``` +fn resolve_codebook(classid: u32) -> Codebook { + match (classid >> 16) as u16 { + 0x0000 => Codebook::Core, // shared canon (today's behaviour) + app => Codebook::App(app), // app-private namespace + } +} +// domain within a codebook is still the low-u16 high byte: +fn domain_in(classid: u32) -> u8 { (classid >> 8) as u8 } +``` + +For `hi = 0x0000` this is **bit-identical to today** — no regression, +no version bump. App-private codebooks add their own `(app → domain map)` +record next to their `ClassView` in the registry (the codebook-mints- +with-the-class shelf the canon already describes). This is the same +"the classid group sits in front of the path bytes; codebooks are +selected by the key's own prefix" rule, now exercised at the u16 +granularity instead of only at the byte granularity. + +--- + +## 5. Invariants (proposed; pin on 5+3 pass) + +- **I-APP1 — additive:** every existing classid is `0x0000_DDCC`; the + high u16 was always present and zero. No id re-numbers. +- **I-APP2 — core is shared:** canonical concepts live in `hi = 0x0000` + and are pulled by every consumer. An app never re-numbers a canonical + concept into its own prefix. +- **I-APP3 — private is the exception:** an app mints `hi = 0xAAAA` + classes only for objects that fail the "would a second consumer reuse + this?" test. Default is map-onto-core. +- **I-APP4 — reserve, don't reclaim:** app prefixes are reserved once + and never re-assigned; promotion (private→core) leaves the private id + as a deprecated alias; demotion never happens. +- **I-APP5 — zero version cost:** because classid keeps its fixed 4-byte + offset, waking a non-zero high u16 changes no `ENVELOPE_LAYOUT_VERSION` + and breaks no v1 reader of a `0x0000_*` key. +- **I-APP6 — domain map is codebook-local:** `0x09 = health` is true in + the core codebook; an app-private codebook defines its own domain + bytes and must ship its `(app → domain)` record with its ClassView. + +--- + +## 6. What this is NOT + +- **Not SoA versioning.** `ENVELOPE_LAYOUT_VERSION: u8 = 2` is the SoA + version, a separate byte. The high u16 of classid has nothing to do + with it (the question that prompted this doc). +- **Not a per-app bridge.** The app-private codebook is *data* (a class + block + a PortSpec), authored in OGAR, named by classid. It is not a + consumer-side bridge crate. `CONSUMER-MIGRATION-HOWTO.md` still holds: + pull classid, enrich, authorize. +- **Not a license to mint freely.** Map onto core first. The escape + hatch is for capacity/specificity, not convenience. diff --git a/docs/APP-CODEBOOK-MIGRATION-PLAN.md b/docs/APP-CODEBOOK-MIGRATION-PLAN.md new file mode 100644 index 0000000..53f36f0 --- /dev/null +++ b/docs/APP-CODEBOOK-MIGRATION-PLAN.md @@ -0,0 +1,295 @@ +# APP-CODEBOOK MIGRATION PLAN — Odoo · WoA · SMB · q2(Gotham/aiwar/neo4j) + +> Companion to `APP-CLASS-CODEBOOK-LAYOUT.md` (the layout) and +> `CONSUMER-MIGRATION-HOWTO.md` (the generic steps). This doc applies +> the **APP‖class** model (`classid = APP(hi u16) ‖ class(lo u16)`) to +> each remaining app and orders the work. +> +> Status: **PLAN**. Append-only. The minting steps are gated on the +> 5+3 codebook pass; nothing here mints a classid yet. + +--- + +## The one decision every app makes first + +For each surface entity, the app answers **one** question: + +> *Would a second, unrelated consumer reuse this concept verbatim?* + +- **Yes →** map onto a **CORE** classid (`hi = 0x0000`) via the app's + OGAR `PortSpec`. No private mint. This is the default and the + overwhelming majority. +- **No →** mint an **APP-private** classid (`hi = 0xAAAA`) in the app's + reserved codebook namespace. Escape hatch only. + +The migration is then identical to `CONSUMER-MIGRATION-HOWTO.md`: pull +classid → enrich by classid → authorize by classid → delete any bridge. +The APP‖class model changes only *where new ids come from* when an app +genuinely needs a private one. + +**Reserved prefixes** (from `APP-CLASS-CODEBOOK-LAYOUT.md` §2): +`0x0001` OpenProject · `0x0002` Odoo · `0x0003` WoA · `0x0004` SMB · +`0x0005` Medcare · `0x0006` q2 · `0x0007` Redmine. `0x0000` is core +(auth lives here at domain `0x0B`). + +--- + +## Wave order (cheapest, most-grounded first) + +| Wave | App | Port status | Private codebook needed? | Gating | +|---|---|---|---|---| +| **W0** | OpenProject (`0x0001`) + Redmine (`0x0007`) | `OpenProjectPort` ✅ + `RedminePort` ✅ | no (maps to project-mgmt `0x01`) | none — flagship repoint + the showcase | +| **W1** | WoA / woa-rs | `WoaPort` ✅ (OGAR #93) | no (maps to commerce `0x02`) | none — pure repoint | +| **W2** | SMB / smb-office-rs | `SmbPort` ✅ (OGAR #93) | no (maps to commerce `0x02`) | none — pure repoint | +| **W3** | Odoo / odoo-rs | `OdooPort` ✅ (OGAR #94) | no, but **delete the fork** | converge `od-ontology` | +| **W4** | q2 (Gotham/aiwar/neo4j) | ❌ no port | **likely yes** (`0x0006`) | author port FIRST | + +W0 is the **flagship** (ports exist, project-mgmt `0x01XX` is the +most-developed core block at 25 concepts, and OpenProject ↔ Redmine is +the cleanest two-renders-one-concept proof). W1–W2 are mechanical (ports +exist, concepts are canonical commerce). W3 is the severity case +(odoo-rs re-derives the AR layer). W4 is greenfield (no port, domain not +yet in the codebook). + +--- + +## W0 — OpenProject (`0x0001`) + Redmine (`0x0007`) (consume project-mgmt `0x01`) — THE FLAGSHIP + +**Ports:** `OpenProjectPort` ✅ and `RedminePort` ✅ both exist. The +project-mgmt block is the most-developed core domain — 25 canonical +concepts (`project = 0x0101` … `project_enabled_module = 0x011A`), +already encoded as "OpenProject ↔ Redmine" curator aliases collapsing to +one id. **Private codebook: no** — every project concept is canonical. + +**Why this is the showcase.** OpenProject and Redmine are **two +renderers of the same canonical concepts**. The hi/lo split +(`APP-CLASS-CODEBOOK-LAYOUT.md` §1) is at its cleanest here: + +| Surface name | shared concept (lo u16) | OpenProject id | Redmine id | +|---|---|---|---| +| WorkPackage / Issue | `0x0102` project_work_item | `0x0001_0102` | `0x0007_0102` | +| Project | `0x0101` project | `0x0001_0101` | `0x0007_0101` | +| Role | `0x0117` project_role | `0x0001_0117` | `0x0007_0117` | +| TimeEntry / billable | `0x0103` billable_work_entry | `0x0001_0103` | `0x0007_0103` | + +- **Same low half ⇒ same RBAC + ontology.** Both authorize on `0x0102`; + both inherit the `project_role 0x0117` grant lattice — which is already + the keystone's worked example (`CLASSID-RBAC-KEYSTONE-SPEC.md` §4, + harvested from the Rails/Redmine model). One grant lattice, two apps. +- **Different high half ⇒ different render.** `WorkPackage` renders via + OpenProject's ClassView/template (`0x0001`); `Issue` renders via + Redmine's (`0x0007`) — distinct field layouts, distinct Askama + templates, **zero** concept duplication. This is precisely why the + high u16 exists (§3.5). + +Steps (OpenProject-nexgen-rs is the live migrating consumer; Redmine is +the alias-twin that proves the model — any Redmine Rust consumer follows +the identical pattern): +1. **Pull the concept low half statically.** Repoint off any + `OpenProjectBridge` / `RedmineBridge` usage to + `OpenProjectPort::class_id(name)` / `RedminePort::class_id(name)`; + form the render classid `app_prefix << 16 | concept` (e.g. + `0x0001_0000 | 0x0102`). +2. **Delete the bridges.** No hand-rolled registry/hydration; the + consumer holds no ontology. +3. **Enrich by classid.** Redmine's per-project visibility + + role-based access, OpenProject's work-package permissions, become the + row-scope axis — compiled to a bitmap, **not** runtime domain-eval + (Firewall). +4. **Authorize by the low half.** `authorize(actor, 0x0102, op)` — the + shared project grant lattice. Both apps, one upstream resolution. +5. **Render by the full classid.** Each app's ClassView selects its own + template; fields resolve key-value against content stores (no serde — + `APP-CLASS-CODEBOOK-LAYOUT.md` §3.6). +6. **Private mint only if** an app ships a genuinely non-canonical + project object (none known today) → `0x0001_FFCC` / `0x0007_FFCC`. + +Cross-ref: `OPENPROJECT-TRANSCODING.md`, +`CLASSID-RBAC-KEYSTONE-SPEC.md` §4 (the project_role lattice is the +canonical RBAC worked example). + +--- + +## W1 — WoA / woa-rs (`0x0003`, consumes commerce `0x02`) + +**Port:** `WoaPort` exists (OGAR #93), maps `WorkOrder` and friends onto +the commerce block. **Private codebook: no** — work orders, line items, +invoices, dunning stages are canonical commerce concepts (`0x0000_02CC`). + +Steps: +1. Repoint `src/registry.rs`, `src/unified_bridge.rs`, `src/lib.rs`, + `tests/…` off `lance_graph_ontology::bridges::WoaBridge` to the + static pull: `WoaPort::class_id(name) -> Option`, widened to + `0x0000_0000 | id` at the u32 boundary. +2. Delete `WoaBridge` (= `UnifiedBridge`) + any hand-rolled + registry/hydration. +3. Enrich by classid (tenant scoping, Mahnwesen stage rules) — this is + woa-rs's legitimate domain logic. +4. Authorize by classid once the keystone lands (the `perm_buchhaltung` + / `@login_required` checks become `authorize(actor, cid, op)`). +5. **Iron Rule 1 (woa-rs CLAUDE.md):** the deps you keep are + `ogar-vocab` (port) + `lance-graph-rbac` (authorize) — both BBB-tier, + not brain crates. **File the allow-list RFC** (`rfcs/`) for that + delta before merging; never add a `lance-graph-*` non-allow-listed + crate. +6. **Private mint only if** WoA has a genuinely non-canonical object + (e.g. a Stefan-specific KeePass-vault row with no commerce analogue) + → `0x0003_FFCC`. Default: none. + +Spec cross-ref: `woa-rs/.claude/board/` OGAR-migration note; +behaviour-parity stays the witness (Python writer ↔ Rust writer). + +--- + +## W2 — SMB / smb-office-rs (`0x0004`, consumes commerce `0x02`) + +**Port:** `SmbPort` exists (OGAR #93). **Private codebook: no** — SKR04 +accounts, customers, suppliers, work orders, FiBu reconciliation map +onto canonical commerce (`0x0000_02CC`). + +Steps: +1. `crates/smb-bridge/src/unified_bridge_wiring.rs` drops `OgitBridge`; + pull `SmbPort::class_id`. +2. Delete the bridge wiring; the consumer holds no ontology. +3. Enrich by classid (German BSON field-name mapping stays at the + adapter — `mongo-schema-warden` invariant; PII never leaves). +4. Authorize by classid once the keystone lands. +5. **Iron rule 3 (smb-office-rs CLAUDE.md):** `lance-graph` is + additive-only — this migration edits **only** smb-office-rs + (if a + port gap) OGAR. The spine is untouched. +6. **Private mint only if** an SMB object has no commerce analogue → + `0x0004_FFCC`. Default: none. + +Tracked: `smb-office-rs/.claude/board/TECH_DEBT.md` +`TD-OGAR-CONSUMER-MIGRATION-1`. + +--- + +## W3 — Odoo / odoo-rs (`0x0002`, consumes commerce `0x02`) — SEVERITY CASE + +**Port:** `OdooPort` exists (OGAR #94). **Private codebook: no.** The +problem is not the ids — it's that odoo-rs **forks the AR layer OGAR +exists to own**: its bespoke `od-ontology::{surreal_ast,triple,emit}` +re-derives `op-surreal-ast` / `ogar-adapter-surrealql` and **never +touches `ogar-vocab`**. + +Steps (this is convergence, not just a repoint): +1. Lower `od-ontology` onto `ogar_vocab::Class` — its model objects + (sale.order, account.move, res.partner, product.template, …) map onto + canonical commerce classids via `OdooPort`. +2. Emit SurrealQL via **`ogar-adapter-surrealql`** (the canonical + emitter), not the `od-ontology::emit` fork. +3. **Delete the fork** (`surreal_ast` + `triple` + `emit`). This is the + deliverable — the fork is the debt. +4. Odoo's `ir.model.access` (class-grant) and `ir.rule` (row-scope) + become the two RBAC axes the keystone already models + (`CLASSID-RBAC-KEYSTONE-SPEC.md` §4): class-grant on the role class, + row-scope compiled to a bitmap (NOT runtime domain-eval — that would + violate the Firewall). +5. **Private mint only if** an Odoo module ships an object with no + canonical commerce analogue → `0x0002_FFCC`. Most map onto core. + +Cross-ref: `ODOO-TRANSCODING.md`, `SURREAL-AST-AS-ADAPTER.md`. + +--- + +## W4 — q2 (Gotham / aiwar / neo4j) (`0x0006`) — AUTHOR PORT FIRST + +**Port:** ❌ none. This is the gate: until q2's domain entities are +mapped onto canonical `class_id`s in OGAR, q2 cannot pull classids. +q2 is also the app **most likely to need a private codebook** — its +graph/intel entities (Gotham nodes, aiwar war-game objects, neo4j +node/relationship types) are not all canonical, and `0x07 osint` in +core is thin. + +Sub-steps, in order: +1. **Triage which sub-surfaces are live.** q2 spans Gotham, aiwar, and a + neo4j compat layer. neo4j-rs is **legacy** (superseded by lance-graph + as L3) — confirm it is still a consumer before authoring its port. If + dead, skip it. +2. **Author `Q2Port: PortSpec`** in `ogar-vocab::ports` (its own OGAR + PR). Map q2's public entity names → classids. For entities that ARE + canonical (a generic `document`, `person`, `organization`, + `location`), map onto core (`0x0000_07CC` osint or the relevant + domain). For entities that are q2-specific (a Gotham investigation + case, an aiwar scenario branch, a neo4j-native node label with no + canonical analogue), mint **app-private** under `0x0006`: + ``` + 0x0006_01CC q2/Gotham object classes + 0x0006_02CC q2/aiwar scenario + branch classes + 0x0006_03CC q2/neo4j legacy node/rel adapter classes (if still live) + ``` + This is the operator's "codebook per project" win in its purest form: + q2 gets a full 65 536-class private space, its own centroid-codebook + hierarchy, **without** overflowing the shared core radix trie. +3. **Then the generic steps apply** (`CONSUMER-MIGRATION-HOWTO.md`): + pull classid → enrich → authorize → no bridge. +4. **scenario/time-travel note:** aiwar's "what-if" branching maps onto + the existing scenario inventory (Lance version time-travel / + `World::fork` / Pearl Rung-3), **not** a new `scenario_id` column — + spawn `scenario-world` before proposing anything there. + +Cross-ref: q2 has no OGAR transcoding doc yet — **authoring +`Q2-TRANSCODING.md` is part of W4** (mirror `ODOO-TRANSCODING.md`). + +--- + +## Rendering convergence (all waves) — key-value, zero serde in the hot path + +Every app's render path migrates to the same shape +(`APP-CLASS-CODEBOOK-LAYOUT.md` §3.5–3.6): + +- **Template dispatch becomes classid-driven.** Replace ad-hoc + `match entity_kind { … }` template selection with the object's full + classid → `ClassView::resolve(classid)` → Askama template. The **high + u16** is the app's render prefix; the **low u16** is the shared concept + (RBAC + ontology). woa-rs and smb-office-rs already use Askama + (compile-time-checked) — only the *selection* changes, not the engine. +- **Fields become keys, not blobs.** Strings, text, media, online + sources resolve by key-value lookup against typed content stores + (string dictionary / text column / media bytes / URI registry) — never + serialized into the row and parsed back. If a render step needs + `serde::Deserialize`, the field was inlined as a blob: that is the + Firewall violation; make it a key. +- **DoD render litmus:** grep the app's render path for + `serde_json::from_*` / `Deserialize` on the hot path — there should be + none. Content is addressed, not deserialized. + +This is the operator's stated goal: strings / text / media / online +sources rendered via key-value, so no serialization exists in the hot +path. The per-app classid (high u16) is what makes per-app rendering +scale without forking concept ids or overflowing the shared codebook. + +## Convergence with the RBAC keystone (all waves) + +Every wave's step 4 ("authorize by classid") lands the same way once +`lance-graph-rbac` ships (`CLASSID-RBAC-KEYSTONE-SPEC.md`): + +``` +authorize(actor, classid, op) -> Allow | Deny + where actor = its membership set (I-K6), + grants resolve up the ROLE lattice (ReBAC, not the class lattice), + scope (row-level) compiles to a bitmap, never runtime-walked. +``` + +The auth providers (Zitadel / Zanzibar / Ory-Keto) are **core** +preminted class profiles (`0x0000_0BCC`), so token→actor resolution is +shared by every app — no per-app auth wiring. An app hands a classid; +the grant lattice is upstream. Until the keystone lands, each app keeps +its existing auth (do NOT reintroduce a bridge as a stopgap). + +--- + +## Definition of done (per app) + +1. The app's OGAR `PortSpec` exists and resolves every surface entity + (to a core id, or to a reserved app-private id). +2. No `XBridge` / `UnifiedBridge<…>` symbol survives in the app repo. +3. The classid pull is a pure static function call (no `Registry`, no + `hydrate`). +4. The diff touches **only** OGAR (port + any private class block) + the + app's own crate. `lance-graph-ogar` / `lance-graph-rbac` are + byte-for-byte unchanged. (If you edited the spine, you did it wrong.) +5. Private mints, if any, are justified by the "would a second consumer + reuse this?" test in the PR description, and reserved under the app's + `0xAAAA` prefix — never flat in core, never in another app's prefix. diff --git a/docs/CLASSID-RBAC-KEYSTONE-SPEC.md b/docs/CLASSID-RBAC-KEYSTONE-SPEC.md new file mode 100644 index 0000000..c7d8126 --- /dev/null +++ b/docs/CLASSID-RBAC-KEYSTONE-SPEC.md @@ -0,0 +1,190 @@ +# CLASSID-RBAC KEYSTONE — SPEC v2 (hardened) + +> v1 → v2 after the full hardening arc: an 8-agent 5+3 pass, an 8-agent +> "best-of-prior-art" pass, a registry-RBAC prior-art map (Postgres / +> Odoo / Django / Redmine), a NIST RBAC deep-dive, and an OAuth/OIDC + +> Zitadel convergence check. v1 was BLOCK'd by overclaim-auditor on two +> open questions (Q1 deny, Q3 lattice); **v2 closes both** via the +> ReBAC reframe below. Zero remaining BLOCK. +> +> **Unit pin (theorem-checker rule 0):** `ClassId = u16`, hex +> (`0x09XX`). This is the **contract-tier** discriminator +> (`lance_graph_contract::class_view::ClassId`), distinct from the +> on-disk node-key `classid (u32, 8 hex)` in the GUID canon — related by +> zero-extension, NOT the same field. Roles, memberships, member-roles, +> actors are themselves classids (`0x0117 / 0x0108 / 0x0118 / 0x0104`). + +--- + +## I-K0 — THE REGISTRY AXIOM (foundational) + +OGAR is a **DTO registry**. The **label** (classid + canonical name) is +the **KEY**; the **meaning** (schema / DTO shape / grant) is the +**VALUE**. "OGAR inherits SCHEMA" = the *value* composes up the class +hierarchy (the key prerenders the node; the value resolves, and may be +CAM-compressed — the GUID-canon key-value model). **Roles and +permissions are registry/CAM entries — label→meaning — never text +blobs.** Replacing `project_role.permissions: text` with first-class +registry permission tuples is the load-bearing Core change (§6). + +## 0. The spine — ReBAC relationship-tuples, COMPILE-resolved + +Authorization is a graph of **typed relationship tuples** +`classid # relation @ subject` — Google Zanzibar / Ory-Keto / OpenFGA +shape, which is *literally* OGAR's "RBAC is a relation, not a stamp" +(revert `87e8dd2`). This is **ReBAC, not NIST RBAC** — and that label is +deliberate: the NIST deep-dive proved object/class-hierarchy grant +propagation is **not** RBAC (NIST objects are a flat set) but **is** the +recognized ReBAC userset-rewrite mechanism. Calling it ReBAC turns a +flagged "invention" into a citation, and **dissolves the two-lattice +worry**: role-membership, role-implication, and class-parent are just +**different relation types in one tuple graph**, not conflated lattices. + +**OGAR's differentiator (the reason this isn't a Keto clone):** Keto / +OpenFGA / SpiceDB **walk the tuple graph at request time** (the Check +API — a runtime traversal). That is the hot-path interpretation the +**Firewall (ADR-022/023) forbids**. OGAR **compiles** tuple resolution +into the `classid` prefix + CAM/palette bitmaps (the `_effectiveReaders` +precompute) — **"Zanzibar resolved at the key, not at the query."** +There is no shipped Rust compiled-Zanzibar; OGAR is it. + +## 1. Why (the defect removed) + +Today authz is stringly + per-consumer: each consumer hand-rolls a +`UnifiedBridge` whose `authorize_*` calls +`Policy::evaluate(actor_role: &str, entity_type: &str, op)` +(`lance-graph-rbac/policy.rs:33`). The role/membership relation exists as +**inert edge-data** in OGAR (`project_role/membership/member_role`) but +nothing traverses it, and the decision keys on `entity_type: &str`, never +on `classid`. v2 makes the relation real, tuple-shaped, classid-keyed. + +## 2. Invariants (PROPOSED — none enforced until the Z-items land) + +- **I-K1 — classid is the currency.** Decisions key on `classid`; name→classid is an OGAR adapter (`HealthcarePort::class_id`) resolved *before* authorize. +- **I-K2 — class inheritance carries SCHEMA, role hierarchy carries GRANTS (the correction).** Field/DTO shape inherits up the **class** lattice (`class_ancestors` + `FieldMask::inherit`). **Grants do NOT** — they inherit up the **role** lattice (`implied_roles`; Postgres `INHERIT` / Odoo `implied_ids`). No prior-art system inherits grants up the object hierarchy (Postgres table-inherit explicitly does not propagate GRANTs). [resolves Q3] +- **I-K3 — the spine names no consumer.** `lance-graph-rbac` / `lance-graph-ogar` speak only classid + tuples. One shared-spine edit to `callcenter::UnifiedBridge`, then zero *new* per-consumer bridges. +- **I-K4 — contract handshake, no heavy dep.** rbac consumes a zero-dep `lance_graph_contract::ClassRbac` trait; the **impl lives in `ogar-class-view`** (which already deps both `ogar-vocab` and `lance-graph-contract`, and already `impl ClassView`). rbac never deps `ogar-vocab`. +- **I-K5 — positive grants; deny/scope are separate explicit channels.** The grant stage is positive-only tuples (`R⁺`). Deny is explicit exclusion (OpenFGA `but not` / Postgres restrictive default-deny) and instance scope is a row-predicate — **both routed to the scope axis, never folded into the grant set.** [resolves Q1 — `held ∩ reach` stays monotone because signs live elsewhere] +- **I-K6 — actor = its memberships, sourced from the token.** An actor is the set of `project_membership (0x0108)` tuples it holds; in production these arrive as **OIDC token claims** (the `sub` + role/org claims ARE the membership assertions). `ActorId` is opaque and accepts an external IdP subject. +- **I-K7 — Firewall: parse once, decide on the key.** The token (and any tuple source) is a **boundary** artifact, parsed once at the membrane → resolved to compiled classid/CAM. The inner decision is a bit-op, never a runtime interpreter (this is why Odoo `ir.rule` runtime-domain-eval and the Zanzibar Check-API runtime walk are adopted as *semantics*, not *mechanism*). +- **I-K8 — four orthogonal axes, never collapsed.** `(verb × class)` ⟂ `role-hierarchy` ⟂ `row-scope` ⟂ `field-projection`. Odoo is the only prior art that keeps all four in separate stores; OGAR does too. + +## 3. The four axes (one tuple-relation type each) + +| Axis | Best-of source | OGAR form | +|---|---|---| +| **1. class-grant** (verb × class) | **Odoo `ir.model.access`** | typed registry/CAM tuple `(role_classid, target_classid, op_mask)` — a value-tenant on `project_role 0x0117`, palette-native (#511 `SoaMemberSpec`), replacing `permissions: text`. NOT Django string codename, NOT Postgres `aclitem` (grantor = delegation state). | +| **2. role-hierarchy** (grant inheritance) | Postgres `INHERIT` / Odoo `implied_ids` | `project_role.implied_roles` edge; `roles_reaching` folds **role** ancestors. Caveat: Odoo `implied_ids` breaks the strict partial order (a user keeps an implied role after the implier is removed) — OGAR uses a **true partial-order** closure (NIST RH). | +| **3. row-scope** (g-lock / tenant) | Odoo `ir.rule` *semantics* / Postgres RLS *default-deny* | a separate predicate facet keyed `(role_classid, target_classid) → ScopeSpec`, **compiled** to a palette/Hamming bitmap (NOT a runtime domain). Carries the re-instated Redmine sign-columns (`*_visibility`, `assignable`) + the `org`/tenant claim. | +| **4. field-projection** | OGAR-native (`FieldMask`) | `FieldMask` over the class field-basis, inherited up the **class** lattice via `FieldMask::inherit`; `authorize` carries the granting roles' unioned mask. | + +## 4. The contract seam + +```rust +// lance-graph-contract (zero deps) — OGAR implements, rbac consumes +pub trait ClassRbac { + fn roles_reaching(&self, class: ClassId) -> RoleSet; // role-hierarchy folded; positive R⁺ + fn actor_roles(&self, actor: ActorId) -> RoleSet; // membership → member_role → role + fn row_scope(&self, role: ClassId, class: ClassId) -> Option; // compiled predicate + fn field_mask(&self, role: ClassId, class: ClassId) -> FieldMask; // axis 4 +} +``` +Impl: `impl ClassRbac for OgarClassView` (same struct that already impls +`ClassView`). `roles_reaching` keeps `grants(R)` (direct) queryable for +audit/provenance — do not let the closure erase the direct/inherited +distinction. + +## 5. authorize — two-stage, positive ∧ scope, projection-carrying + +```rust +pub fn authorize(rbac: &impl ClassRbac, actor: ActorId, class: ClassId, op: Operation) + -> AccessDecision { + let granting = rbac.actor_roles(actor) ∩ rbac.roles_reaching(class); // positive, monotone + if granting.is_empty() { return Deny; } + if !granting.any(|r| grant_permits(r, class, op)) { return Deny; } // op_mask gate + // stage 2: compiled row-scope predicate (restrictive default-deny) + let scope = granting.filter_map(|r| rbac.row_scope(r, class)); // AND globals, OR group-rules + let mask = granting.map(|r| rbac.field_mask(r, class)).fold(inherit); // axis 4 union + Allow { scope_predicate: scope, projection: mask } // Allow CARRIES scope + mask +} +``` +Mirrors Odoo `ir.model.access ∧ ir.rule` / Postgres `aclmask ∧ RLS`, +hardened to restrictive default-deny. + +## 6. OGAR Core changes (deliberate, calibrated) + +- **Replace `project_role.permissions: text`** (`lib.rs:2812`) with a typed `granted` value-tenant: `Set<(target_classid: u16, op_mask: u8)>` (Odoo `ir.model.access` shape; #511 width-calibration = max grants/role, low tens → one palette column). +- **Add `project_role.implied_roles`** (role-hierarchy edge; the primitive Redmine lacks, Odoo/Postgres prove). +- **Add a `row_scope` facet** keyed `(role, class) → ScopeSpec` reusing the existing `KausalSpec`/`scope_source` machinery; compiled, not interpreted. +- **Re-instate the dropped Redmine sign-columns** (`*_visibility`, `assignable`) onto the scope axis (the harvest flattened them). +- `class_ancestors` (Z1) stays — but **only for SCHEMA / `FieldMask` (axis 1/4 shape), never for grants** (I-K2). +- `actor_roles` (Z2) unions `inherited_from` member-roles, with individual-revocation honored. +- EdgeBlock (membership triangle `0x0108/0118/0104`) untouched — ratified from the Rails/Redmine harvest, not re-imported. + +## 7. OAuth / OIDC boundary — the AuthStore class family (preminted profiles) + +> **Correction (2026-06-22, supersedes the `0x011B`–`0x011E` ids below):** +> auth classes do NOT belong in the project block (`0x01XX`). Per +> `APP-CLASS-CODEBOOK-LAYOUT.md` §2, auth is a **core domain of its own, +> `0x0B`** (cross-app, provider-agnostic profiles → core, `hi = 0x0000`). +> Mint: `auth_store = 0x0000_0B01`, `auth_zitadel = 0x0000_0B02`, +> `auth_zanzibar = 0x0000_0B03`, `auth_ory_keto = 0x0000_0B04`. The +> `0x011B`–`0x011E` ids in this section are the earlier (project-block) +> draft, retained for provenance only — use the `0x0B` domain. +> Everything else in §7 (the mapping behaviour, I-K7, the Zitadel 1:1) +> stands unchanged. Auth classes still target `actor 0x0104` / `role +> 0x0117` whose low halves are shared core concepts. + +The IdP→classid mapping is not a service and not a scattered set of hooks — per the registry axiom (I-K0) **the bridge IS a registry class**, preminted in the codebook: + +- **`auth_store` (0x011B)** — the base. It *does the mapping*: `sub → actor (0x0104)`, `role-key → role (0x0117)`, `org/tenant → scope` (axis 3). Carries the three claim-name slots as attributes. +- **`auth_zitadel` (0x011C)**, **`auth_zanzibar` (0x011D)**, **`auth_ory_keto` (0x011E)** — preminted **provider profiles**, each is-a `auth_store`, carrying that provider's claim grammar as data: Zitadel (`urn:zitadel:iam:org:project:roles` + org URN), Zanzibar/OpenFGA (the `object#relation@subject` tuple grammar), Ory Keto. Keycloak / Auth0 follow the identical mint. + +Selecting a provider = **picking its preminted classid** — zero spine change, one data profile per IdP. The inner `authorize` kernel never touches a token (I-K7): the token is parsed once at the membrane, the chosen `auth_store` profile resolves its claims to classids/tuples, and only resolved keys go inward. **Zitadel maps 1:1** (Project→class scope, Project-Role→role, Authorization/Grant→membership tuple, Organization→scope, User→`sub`). + +## 8. Where OGAR genuinely DIFFERS from every twin (honest section) + +- **Compile-time codebook ≠ runtime catalog.** Roles/grants are *minted classids* (schema), not inserted rows. The grant *map* can be runtime; the roles are not. +- **CAM / key-addressed ≠ row-addressed.** No per-row anything inside; scope is a compiled bitmap, not a walked predicate. +- **Actor = membership-set, no subject table** (the identity model is the OIDC `sub`, resolved to tuples). +- **It's ReBAC (Zanzibar), not NIST RBAC** — own the label. + +## 9. Open questions — RESOLVED + +- **Q1 (deny):** RESOLVED — grants are positive `R⁺`; deny = explicit exclusion + scope predicate (I-K5). Intersection stays monotone. +- **Q3 (lattice):** RESOLVED — grants ride the **role** lattice (I-K2); class lattice carries schema only. The object-hierarchy reach is ReBAC, named, not mislabeled RBAC. +- **Q4 (projection collapse):** RESOLVED — `authorize` returns the unioned `FieldMask` (§5); axis 4 stays separate. +- **Q5 (dep tier):** RESOLVED — impl in `ogar-class-view` (already deps both); rbac stays contract-tier (doctrine-keeper). + +## 10. The gate before any consumer code — `PROBE-OGAR-RBAC-AUTHORIZE` + +`authorize(actor, classid, op)` + the scope predicate must reproduce a +reference system's decision **bit-for-bit** on a fixed corpus (Odoo +`ir.model.access ∧ ir.rule`, or Redmine `User#allowed_to?`, or an +OpenFGA model) before consumer-collapse (step 5) lands. Until green, the +keystone is **CONJECTURE**. + +## 11. Build / PR order + cross-refs + +Order: **(1)** `lance-graph-contract` `ClassRbac` trait → **(2)** OGAR +`ogar-vocab` Core changes (§6) + `ogar-class-view` impl → **(3)** +`lance-graph-rbac` `authorize` + re-key `PermissionSpec` to `ClassId` +→ **(4)** `PROBE-OGAR-RBAC-AUTHORIZE` green → **(5)** consumer collapse +(medcare #169 gate → `authorize(actor, HealthcarePort::class_id("Patient"))`; +`MedcareBridge`/`MedcareRegistry`/`medcare-bridge` evaporate — but the +**g-lock scope** moves to axis 3, it does not vanish). + +Docs to update in lockstep (doctrine-keeper): `DISCOVERY-MAP.md` +(D-entry + `87e8dd2` provenance), `INTEGRATION-MAP.md` (`ClassRbac` +seam + F-gates), `ADAPTERS-AND-ACTORS.md` / `IDENTITY-MAPPING.md` +(I-K6, OIDC `sub`), `HEALTHCARE-TRANSCODING.md`, `ODOO-TRANSCODING.md:556` +(`ir.rule`-out cross-ref to THE-FIREWALL §3), `ARCHITECTURAL-DECISIONS` +(ADR-026), and lance-graph board hygiene (`LATEST_STATE` Contract +Inventory + `PR_ARC_INVENTORY`). + +## 12. Hardening record (condensed) + +- **5+3 (v1):** runtime-archaeologist PASS (grounding exact); core-first TARGETS-CORE; doctrine-keeper PASS (impl-site = ogar-class-view); theorem-checker/core-gap/dilution/overclaim/arxiv CONCERNS → the fixes folded above. +- **Best-of-prior-art (8 agents):** unanimous — Odoo registry decomposition as skeleton, Rails/Redmine membership triangle (already harvested), Postgres restrictive-default + role-`INHERIT`; **drop the class-grant-walk**; Django (string codename) and Postgres `aclitem` (grantor state) are storage anti-patterns; Frankenstein guard = never fuse role-lattice and class-lattice. +- **NIST deep-dive:** object-hierarchy grant propagation is **ReBAC, not RBAC** — drove the §0 reframe. +- **OAuth/Zitadel convergence:** Zanzibar tuple ≅ `classid::role::membership`; token = membrane carrier; Zitadel native 1:1 (§7). diff --git a/docs/CONSUMER-MIGRATION-HOWTO.md b/docs/CONSUMER-MIGRATION-HOWTO.md new file mode 100644 index 0000000..aa92b51 --- /dev/null +++ b/docs/CONSUMER-MIGRATION-HOWTO.md @@ -0,0 +1,114 @@ +# CONSUMER MIGRATION HOW-TO — onto OGAR classid-first + +> Audience: every downstream consumer of the ontology/bridge surface — +> **woa-rs, smb-office-rs, odoo-rs, openproject-nexgen-rs, q2, neo4j-rs, +> gotham**, and any future one. One page, same shape for all. + +## The principle (read once) + +A consumer is **enrichment over a `classid`**, nothing more. It does NOT +own an ontology, a registry, or a bridge. It: + +1. **Pulls a `classid`** for its surface entity (static OGAR codebook + lookup), then +2. **Enriches** by that classid (its RLS / masks / projections / domain + behaviour), and +3. **Authorizes** by that classid (`lance_graph_rbac::authorize(actor, + classid, op)` — the keystone, see `CLASSID-RBAC-KEYSTONE-SPEC.md`). + +The class carries its own schema *and* its own access policy (AR/Rails +virtue). The consumer never re-declares either. **Cost of migrating = +one OGAR PortSpec (the adapter) + a thin enrichment module. Zero spine +edits, zero per-consumer bridge.** + +## Prerequisite — does your domain have an OGAR PortSpec? + +A `PortSpec` is the agnostic adapter: **surface name → canonical +`classid`** (`ogar_vocab::ports`). It is *data* (an alias table), lives +in OGAR, names no consumer code. + +| Consumer | PortSpec | Status | +|---|---|---| +| openproject-nexgen-rs | `OpenProjectPort` | ✅ exists | +| (redmine) | `RedminePort` | ✅ exists | +| woa-rs | `WoaPort` (`WorkOrder`) | ✅ exists (OGAR #93) | +| smb-office-rs | `SmbPort` (`SMB`) | ✅ exists (OGAR #93) | +| odoo-rs | `OdooPort` (`Odoo`) | ✅ exists (OGAR #94) | +| **q2** | — | ❌ **author first** (map q2's graph entities → classids) | +| **neo4j-rs** | — | ❌ **author first** (legacy; map its node/rel types → classids) | +| **gotham** | — | ❌ **author first** (map its domain → classids) | + +**If your row is ❌:** the FIRST migration PR is in **OGAR** — add a +`YourPort: PortSpec` to `ogar_vocab::ports` mapping your public names +(EN + native synonyms collapse to one canonical id) onto the existing +codebook `class_ids`. Mint **no** new `class_id` unless your concept is +genuinely new to the codebook (most map onto the `0x01XX`/`0x02XX` +commerce/work blocks or a domain block like Health `0x09XX`). Pattern: +copy `OdooPort` (OGAR #94) or `WoaPort` (#93). Tests: assert +`YourPort::class_id("Name") == Some(0xNNNN)` and the cross-fork +convergence pins. + +## The migration, step by step (identical for every consumer) + +1. **Confirm/author your PortSpec** in OGAR (above). This is the only + per-domain artifact, and it's data. +2. **Pull the classid, statically.** Replace any + `use lance_graph_ontology::bridges::XBridge` / + `use lance_graph_ogar::bridges::XBridge` with the static port lookup: + ```rust + let cid = ogar_vocab::ports::YourPort::class_id(entity_name); // Option + ``` + No registry, no hydration, no construction. (Template: + `MedCare-rs/crates/medcare-analytics/src/rls_policies.rs:170`.) +3. **Delete the per-consumer bridge.** Remove `XBridge` (= the + `UnifiedBridge` alias), any hand-rolled `XRegistry`/hydration, + and the crate/module that existed only to house them. The consumer + holds **no** ontology. +4. **Enrich by classid.** Your domain logic (row-level security, column + masks, projections, view shaping) keys on `cid`. This is the part that + is legitimately yours. +5. **Authorize by classid.** Once the keystone lands, gate access with + `lance_graph_rbac::authorize(actor, cid, op)`. The actor is its + membership set (I-K6); roles/grants/inheritance are resolved upstream. + Until the keystone lands, keep your existing auth (or none) — do NOT + re-introduce a bridge as a stopgap. +6. **Verify (the litmus):** + - `grep` your repo: no `XBridge` / `UnifiedBridge<…>` symbol survives. + - the classid pull is a pure function call (no `Registry`, no + `hydrate`, no `OntologyRegistry` field). + - your diff touches only OGAR (a port, if new) + your own crate. The + agnostic spine (`lance-graph-ogar`, `lance-graph-rbac`) is **byte-for- + byte unchanged**. If you edited the spine, you did it wrong. + +## Per-consumer notes + +- **woa-rs** — `WoaPort` exists. Repoint `src/registry.rs`, + `src/unified_bridge.rs`, `src/lib.rs`, `tests/…` off + `lance_graph_ontology::bridges::WoaBridge` to the classid pull. Iron + Rule 1 (CLAUDE.md) allow-list: the dep you keep is `ogar-vocab` (for the + port) + `lance-graph-rbac` (for authorize) — NOT a brain crate. File the + RFC for the allow-list delta. Spec: `.claude/board/OGAR-MIGRATION-GAP-2026-06-21.md`. +- **smb-office-rs** — `SmbPort` exists. `crates/smb-bridge/src/unified_bridge_wiring.rs` + drops `OgitBridge`; pull `SmbPort::class_id`. Tracked: + `.claude/board/TECH_DEBT.md TD-OGAR-CONSUMER-MIGRATION-1`. +- **odoo-rs** — `OdooPort` exists (OGAR #94). The bigger move: odoo-rs + forks `op-surreal-ast` / `ogar-adapter-surrealql` in its bespoke + `od-ontology::{surreal_ast,triple,emit}` and **never touches + `ogar-vocab`**. Converge it: lower onto `ogar_vocab::Class`, emit via + `ogar-adapter-surrealql`. Delete the fork. (This is the severity + case — it re-derives the AR layer OGAR exists to own.) +- **openproject-nexgen-rs** — `OpenProjectPort` exists. Pull + `OpenProjectPort::class_id`; drop any `OpenProjectBridge` usage. +- **q2 / neo4j-rs / gotham** — **no port yet.** Step 1 (author the OGAR + PortSpec) is the gate; until their domain entities are mapped onto + canonical `class_id`s in OGAR, they cannot pull classids. Define the + port first (its own OGAR PR), then the generic steps apply unchanged. + neo4j-rs note: it is legacy (superseded by lance-graph as L3) — confirm + it is still a live consumer before authoring its port. + +## Why this is the whole story + +Adding or migrating a consumer is **one OGAR port + one enrichment +module**. The spine never learns the consumer's name; every other +consumer is untouched; the class carries its shape and its policy. That +is the agnostic, AR-shaped target — not a bridge pretending to be one. diff --git a/docs/DISCOVERY-MAP.md b/docs/DISCOVERY-MAP.md index 06a548b..8b0e50b 100644 --- a/docs/DISCOVERY-MAP.md +++ b/docs/DISCOVERY-MAP.md @@ -527,3 +527,45 @@ isolation. The map's job is to keep them visible. - **Baldo/Adachi/Forrest** (~100% internal phosphorescence, *J. Appl. Phys.* 90:5048, 2001) + **Uoyama/Adachi** (TADF, *Nature* 492:234, 2012) — the harvest‑the‑dark physics (`[G]`‑real, ≠ the substrate link). + +--- + +- **D-APPCLASS (classid = APP(hi u16) ‖ class(lo u16); 2026-06-22; [H]):** + the GUID's `classid` is u32 (8 hex) but only the low u16 (`0xDDCC` + domain|concept) was ever used; the high u16 was reserved-zero, NOT SoA + versioning (`ENVELOPE_LAYOUT_VERSION: u8 = 2`, + `lance-graph-contract/src/soa_envelope.rs:54`). Claimed: **hi u16 = + APP / codebook-namespace + render prefix; lo u16 = shared canonical + concept.** The two halves carry orthogonal facts — lo = WHAT it is + (RBAC grant + ontology + cross-app identity, shared), hi = WHOSE + rendering (app `ClassView` / Askama template / SoA layout, per-app). + Additive (every existing id is `0x0000_DDCC`); zero + `ENVELOPE_LAYOUT_VERSION` cost (classid keeps fixed key offset 0..4); + RESERVE-DON'T-RECLAIM holds. Resolves the operator's "codebook per + project avoids radix-trie codebook limits" — each app prefix roots its + own centroid-codebook hierarchy + template set. Spec: + `docs/APP-CLASS-CODEBOOK-LAYOUT.md`. Migration (Odoo/WoA/SMB/q2): + `docs/APP-CODEBOOK-MIGRATION-PLAN.md`. Medcare worked example: + patient = `0x0005_0901` (lo `0x0901` shared patient grant+ontology, hi + `0x0005` Medcare clinical template). Gated on the 5+3 codebook pass; + nothing minted yet. `[H]` pending the pass + a render-path probe. +- **D-KV-RENDER (rendering + RAG are key-value egress; 2026-06-22; [H]):** + the operator's stated goal — strings / text / media / online sources + rendered via key-value so **no serialization exists in the hot path** + (the Firewall, ADR-022/023). The registry axiom (I-K0: label=KEY, + meaning=VALUE) applied to *content*: every renderable field is a key + into a typed content store (string dictionary / text column / media + bytes / URI registry), resolved by zero-copy columnar/dictionary + lookup — never inlined as a serde blob. A rendered object is a **tree + of keys**: classid → Askama template (hi u16), each field → content + key. **Two membranes, one rule:** UI render and the RAG-to-LLM path + (rs-graph-llm `graph-flow` over lance-graph retrieval) both move keys + in the hot path and materialize content **only at the membrane, + exactly once** — RAG context is a `Vec` (pointer set), content + lands in the LLM prompt at egress (the MarkovBarrier / "boundary + parsed once" pattern). Litmus: any `serde::Deserialize` / + `serde_json::from_*` on a render or retrieval hot path = a blob that + entered too early; make it a key. Build-time codegen that *generates* + the ClassView from a manifest is fine ("compile types", not hot-path + serde). Detail: `docs/APP-CLASS-CODEBOOK-LAYOUT.md` §3.5–3.7. `[H]` + pending a hot-path no-serde probe across one render + one RAG path.