diff --git a/.claude/board/ISSUES.md b/.claude/board/ISSUES.md index 662ebff1..e6477db0 100644 --- a/.claude/board/ISSUES.md +++ b/.claude/board/ISSUES.md @@ -2,7 +2,9 @@ ## 2026-06-22 — ISS-CONTRACT-APP-PREFIX-MIRROR — `contract::ogar_codebook` lacks the OGAR#97 `APP_PREFIX` / `render_classid_for` mirror, so membrane consumers must hand-stamp the hi-u16 render prefix -**Status:** Open · Owner: lance-graph-contract · Surfaced by: `.claude/knowledge/ogar-consumer-preflight.md` (the consumer spellbook). +**Status:** RESOLVED 2026-06-22 (`claude/contract-app-prefix-mirror`) · Owner: lance-graph-contract · Surfaced by: `.claude/knowledge/ogar-consumer-preflight.md` (the consumer spellbook). + +**Resolution:** `contract::ogar_codebook` now mirrors the hi-u16 APP-prefix layer — `AppPrefix` (the OGAR#95 §2 allocation table as typed data: `0x0001` OpenProject / `0x0002` Odoo / `0x0003` WoA / `0x0004` SMB / `0x0005` Healthcare / `0x0007` Redmine), `render_classid` + `render_classid_for_concept` (compose), `classid_app_prefix` + `classid_concept` (decompose). A membrane consumer (BBB-safe) now pulls BOTH halves from one source — no hand-stamped `0x000N`. Wire-compat parity test `app_prefixes_match_ogar_allocation_table` pins the prefixes against OGAR `PortSpec::APP_PREFIX`; `render_classid_composes_decomposes_and_preserves_the_concept_half` pins the `0x0005_0901` MedCare-patient worked example. Mirrors OGAR#97 (`ogar_vocab::app`), following the OGAR#98 `canonical_concept_name` precedent. `contract::ogar_codebook` mirrors `canonical_concept_id` / `canonical_concept_name` (the lo-u16 concept pull, BBB-safe for membrane consumers woa-rs / medcare-rs / smb-office-rs) but does NOT mirror OGAR#97's `PortSpec::APP_PREFIX` + `render_classid_for` (the hi-u16 render composition: `render_classid = APP << 16 | concept`, OGAR#95 §2). A membrane consumer (BBB-barrier: contract/ontology/callcenter only — `lance_graph_ogar` forbidden) can therefore pull the shared concept but must re-derive the app prefix from the OGAR#95 allocation table by hand. Per Core-First the consumer MUST NOT hard-code `0x000N`. **Fix:** mirror the app-prefix table + a `render_classid` helper into `contract::ogar_codebook` (the `canonical_concept_name` reverse-map mirror, OGAR#98, is the precedent) so the membrane stamps from one source. Interim: the spellbook's Q5 says "stamp from the allocation table." Cross-ref: `.claude/knowledge/ogar-consumer-preflight.md` § "A Core gap this spellbook surfaces"; OGAR#95/#97/#98. diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 96fdd984..aa39a676 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -10,6 +10,12 @@ --- +## 2026-06-22 — IN PR (`claude/contract-app-prefix-mirror`) — `contract::ogar_codebook` APP-prefix (hi-u16) mirror — closes `ISS-CONTRACT-APP-PREFIX-MIRROR` + +Membrane consumers can now pull BOTH halves of a render `classid` BBB-safely from `lance_graph_contract::ogar_codebook` — no hand-stamped `0x000N`. **NEW:** `AppPrefix` enum (the OGAR#95 §2 allocation table as typed data — `Core 0x0000` / OpenProject `0x0001` / Odoo `0x0002` / WoA `0x0003` / SMB `0x0004` / Healthcare `0x0005` / Redmine `0x0007`) with `prefix()` / `from_prefix()` / `render(concept)`; free fns `render_classid(prefix, concept)`, `render_classid_for_concept(AppPrefix, &str)`, `classid_app_prefix(classid)`, `classid_concept(classid)` — the wire-compat mirror of OGAR#97 `ogar_vocab::app` (`render_classid_for::

` / `app_of` / `concept_of`), **no `ogar-vocab` dependency**. Two parity tests: `app_prefixes_match_ogar_allocation_table` (pins the 6 prefixes vs OGAR `PortSpec::APP_PREFIX`) + `render_classid_composes_decomposes_and_preserves_the_concept_half` (pins the `0x0005_0901` MedCare-patient worked example, and that the render lens never perturbs the lo-u16 concept RBAC keys on). Follows the OGAR#98 `canonical_concept_name` mirror precedent. Closes the gap the #591 consumer spellbook surfaced. Contract lib **+2 tests** / +1 doctest; `cargo fmt -p lance-graph-contract --check` clean; `clippy -p lance-graph-contract --all-targets -D warnings` clean (also `--features guid-v2-tail`). (Incidental: the crate-wide `cargo fmt` pass also corrected pre-existing struct-literal/line-width drift in `content_store.rs` — same crate, no behavior change.) Refs: PR (this branch), ISSUES `ISS-CONTRACT-APP-PREFIX-MIRROR` (RESOLVED), `.claude/knowledge/ogar-consumer-preflight.md` § Core-gap (CLOSED), OGAR#97/#98. + +--- + ## 2026-06-20 — golden-image (symbiont) harness shipped to `main`; lance-7 lockstep unified end-to-end `crates/symbiont/` (workspace-`exclude`d) compiles+links the FULL stack into ONE binary — lance-graph + lance7/lancedb0.30 + ndarray + ractor + surrealdb(kv-lance) + OGAR. **Verified green** (real git-deps build, `CARGO_EXIT=0`, 4.3 MB binary runs): unified `lance 7.0.0 / lance-index 7.0.0 / lancedb 0.30.0 / datafusion 53.1.0 / arrow 58` — no lance-6/7 split. It is a **living integration harness** (`Dockerfile` + portable git-deps `Cargo.toml`) that tracks each fork's canonical branch (`master`/`main`), **NOT** a frozen snapshot; every per-session `jirak` branch is stale (HEAD ⊂ main/master, 0 unique commits). **`TD-SURREALDB-KVLANCE-LANCE7` PAID** — surrealdb `main` carries the lance-7 bump. PR #555 adds the 5+3 council `INTEGRATION_PLAN.md` (loose-end ledger → the Spain-grid acceptance gate). **Honest state:** linked into one binary; the *runtime edges* between the five crates are still pending integration (Grid→NodeRow bridge, kanban loop). Battle-test plan (probes A1–E3) queued behind the singleton-BindSpace → SoA switch. Refs: PR_ARC #555, EPIPHANIES `E-GOLDEN-IMAGE-IS-A-LIVING-HARNESS`, AGENT_LOG 2026-06-20. diff --git a/.claude/knowledge/ogar-consumer-preflight.md b/.claude/knowledge/ogar-consumer-preflight.md index 0bf5dcef..ba3fd680 100644 --- a/.claude/knowledge/ogar-consumer-preflight.md +++ b/.claude/knowledge/ogar-consumer-preflight.md @@ -112,8 +112,10 @@ One signature is suspicious; a local codebook copy alone is the trap. 2. **Pull the classid** — pure function, no registry: - spine: `lance_graph_ogar::WoaPort::class_id(name) -> Option` - membrane (BBB): `lance_graph_contract::ogar_codebook::canonical_concept_id(name)` -3. **Stamp the app prefix + delete the old surface.** Render id = - `APP << 16 | cid`; authorize on the shared `cid` (lo u16). Then delete: the +3. **Stamp the app prefix + delete the old surface.** Compose the render id — + membrane (BBB): `ogar_codebook::render_classid_for_concept(AppPrefix::X, name)` + or `AppPrefix::X.render(cid)`; spine: `render_classid_for::(cid)`. Both + are `APP << 16 | cid`; authorize on the shared `cid` (lo u16). Then delete: the `*Bridge` import, any `OntologyRegistry` field, and every local codebook copy. Your diff touches only your crate; the spine is byte-for-byte unchanged. @@ -133,17 +135,27 @@ repo; the classid pull is a pure function call. These three are the migration backlog. The terminal `bridges/` deletion in the spine is gated on all three reaching 0. -## A Core gap this spellbook surfaces (honest — flag, don't paper) - -`contract::ogar_codebook` mirrors `canonical_concept_id` / `canonical_concept_name` -(the lo-u16 pull, BBB-safe) but does **not** yet mirror OGAR#97's -`PortSpec::APP_PREFIX` / `render_classid_for` (the hi-u16 render composition). So -a **membrane** consumer can pull the shared concept but must hand-stamp the app -prefix from the OGAR#95 allocation table — a small re-derivation a -`contract::ogar_codebook::APP_PREFIX` mirror would remove. Per Core-First the -consumer must NOT hard-code `0x000N`; file the contract mirror (the -`canonical_concept_name` precedent is OGAR#98) rather than minting a local -prefix const. Tracked: `ISS-CONTRACT-APP-PREFIX-MIRROR`. +## A Core gap this spellbook surfaced — now CLOSED + +> **Resolved 2026-06-22** (`claude/contract-app-prefix-mirror`, closes +> `ISS-CONTRACT-APP-PREFIX-MIRROR`). `contract::ogar_codebook` now mirrors the +> hi-u16 APP-prefix layer alongside the lo-u16 concept layer: `AppPrefix` (the +> OGAR#95 §2 allocation table as typed data — `0x0001` OpenProject … `0x0005` +> Healthcare … `0x0007` Redmine), `render_classid` / `render_classid_for_concept` +> (compose), `classid_app_prefix` / `classid_concept` (decompose). A **membrane** +> consumer now pulls BOTH halves BBB-safely — +> `render_classid_for_concept(AppPrefix::Healthcare, "patient")` → `Some(0x0005_0901)`, +> no hand-stamped `0x000N`. Wire-compat parity tests pin the prefixes against OGAR +> `PortSpec::APP_PREFIX`. Mirrors OGAR#97 `ogar_vocab::app`, following the OGAR#98 +> `canonical_concept_name` precedent. + +Original gap (kept for the record — honest, flag don't paper): +`contract::ogar_codebook` mirrored the lo-u16 concept pull (`canonical_concept_id`) +but not OGAR#97's `PortSpec::APP_PREFIX` / `render_classid_for` (the hi-u16 render +composition), so a **membrane** consumer could pull the shared concept yet had to +hand-stamp the app prefix from the OGAR#95 allocation table. Per Core-First the +fix was to mirror it into the contract (the `canonical_concept_name` precedent is +OGAR#98), never to mint a local prefix const. ## When this doc fires + trigger phrases diff --git a/crates/lance-graph-contract/src/content_store.rs b/crates/lance-graph-contract/src/content_store.rs index 43f5c0ec..beab424d 100644 --- a/crates/lance-graph-contract/src/content_store.rs +++ b/crates/lance-graph-contract/src/content_store.rs @@ -88,7 +88,11 @@ impl SourceSpan { /// New span; `end` is clamped to be `>= start`. #[must_use] pub fn new(content: ContentId, start: u32, end: u32) -> Self { - Self { content, start, end: end.max(start) } + Self { + content, + start, + end: end.max(start), + } } /// Span length in bytes. Saturating: a malformed span (`end < start`, only @@ -226,7 +230,10 @@ mod tests { fn out_of_bounds_and_missing_fail() { let mut s = MemStore::default(); let id = s.put_str("short"); - assert_eq!(s.resolve_span(SourceSpan::new(id, 0, 999)), Err(ContentError::SpanOutOfBounds)); + assert_eq!( + s.resolve_span(SourceSpan::new(id, 0, 999)), + Err(ContentError::SpanOutOfBounds) + ); assert_eq!( s.resolve_span(SourceSpan::new(ContentId(123), 0, 1)), Err(ContentError::NotFound) @@ -245,7 +252,11 @@ mod tests { fn malformed_span_len_saturates_not_panics() { // Public fields let a consumer build end < start, bypassing new()'s clamp. // len() must saturate to 0 (consistent with is_empty), never panic/wrap. - let bad = SourceSpan { content: ContentId(7), start: 13, end: 0 }; + let bad = SourceSpan { + content: ContentId(7), + start: 13, + end: 0, + }; assert_eq!(bad.len(), 0); assert!(bad.is_empty()); assert!(!bad.is_cited()); diff --git a/crates/lance-graph-contract/src/lib.rs b/crates/lance-graph-contract/src/lib.rs index a3248fcf..0d0d0b3c 100644 --- a/crates/lance-graph-contract/src/lib.rs +++ b/crates/lance-graph-contract/src/lib.rs @@ -142,8 +142,9 @@ pub use episodic_edges::{EdgeRef, EpisodicEdges64}; pub use head2head::{CompetitionOutcome, Head2Head, WinnerCriterion}; pub use kanban::{ExecTarget, KanbanColumn, KanbanMove, RubiconTransitionError}; pub use ogar_codebook::{ - canonical_concept_domain, canonical_concept_id, classid_concept_domain, source_domain_concept, - ConceptDomain, LabelDTO, CODEBOOK, + canonical_concept_domain, canonical_concept_id, classid_app_prefix, classid_concept, + classid_concept_domain, render_classid, render_classid_for_concept, source_domain_concept, + AppPrefix, ConceptDomain, LabelDTO, CODEBOOK, }; pub use scheduler::{DatasetVersion, NextPhaseScheduler, VersionScheduler}; pub use soa_graph::{ diff --git a/crates/lance-graph-contract/src/ogar_codebook.rs b/crates/lance-graph-contract/src/ogar_codebook.rs index b11fbce4..5682bd38 100644 --- a/crates/lance-graph-contract/src/ogar_codebook.rs +++ b/crates/lance-graph-contract/src/ogar_codebook.rs @@ -18,7 +18,12 @@ //! What this mirror carries: the **codebook-id layer** the contract needs to route //! a `classid` to its domain ([`canonical_concept_domain`], [`classid_concept_domain`]) //! and to resolve a canonical-concept string to its id ([`canonical_concept_id`], -//! [`LabelDTO::from_canonical`]). What it does NOT carry: OGAR's curator-alias +//! [`LabelDTO::from_canonical`]). It also carries the **APP / render-prefix +//! layer** (the hi u16): [`AppPrefix`] (the §2 allocation table as typed data), +//! [`render_classid`] / [`render_classid_for_concept`] (compose), and +//! [`classid_app_prefix`] / [`classid_concept`] (decompose) — the membrane +//! equivalent of OGAR `render_classid_for::

()`, so a zero-dep consumer stamps +//! the prefix from ONE source instead of hardcoding `0x000N`. What it does NOT carry: OGAR's curator-alias //! normalizer (`canonical_concept` — the large `"Issue"`/`"WorkPackage"` → //! `"project_work_item"` table). Alias normalization stays in `ogar-vocab`; this //! module resolves canonical-shaped concept strings only (hence `from_canonical`, @@ -96,6 +101,147 @@ pub fn source_domain_concept(source_domain: &str) -> Option { } } +// ── APP / render-prefix layer (the hi u16) — wire-compat mirror of OGAR `ogar_vocab::app` ── + +/// The **APP / render prefix** — the high u16 of a full 32-bit `classid`. +/// +/// A full render classid is two orthogonal halves: +/// +/// ```text +/// classid : u32 = [ hi u16 : APP / render prefix ] [ lo u16 : concept ] +/// 0xAAAA (per-app ClassView lens) 0xDDCC (shared RBAC+ontology) +/// ``` +/// +/// `0x0000` ([`AppPrefix::Core`]) is the shared canonical core — every +/// [`canonical_concept_id`] is `0x0000_DDCC`, additive and invariant. A +/// non-zero prefix selects an app's render lens (its per-app `ClassView` / +/// template set) while the lo-u16 concept — the RBAC + ontology + cross-app +/// identity key — stays shared; concept/domain routing reads only the low half +/// ([`classid_concept_domain`] does `… as u16`), so it is identical under every +/// render prefix. Mirrors OGAR `PortSpec::APP_PREFIX` (the +/// `APP-CLASS-CODEBOOK-LAYOUT.md` §2 allocation table as typed data); +/// wire-compatible, **no `ogar-vocab` dependency**. This is the membrane +/// equivalent of OGAR's `render_classid_for::

()` — the contract carries the +/// prefix as an enum value rather than a `PortSpec` generic, so a zero-dep +/// consumer never hand-stamps `0x000N`. Drift is guarded by +/// [`tests::app_prefixes_match_ogar_allocation_table`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum AppPrefix { + /// `0x0000` — shared canonical core (default `ClassView`, no render lens). + Core, + /// `0x0001` — OpenProject (project-mgmt render lens). + OpenProject, + /// `0x0002` — Odoo (commerce / ERP render lens). + Odoo, + /// `0x0003` — WoA (WorkOrder render lens). + Woa, + /// `0x0004` — SMB-Office render lens. + Smb, + /// `0x0005` — Healthcare / MedCare render lens. + Healthcare, + /// `0x0007` — Redmine (project-mgmt render lens; OpenProject twin at the + /// shared concept level). + Redmine, +} + +impl AppPrefix { + /// The reserved high-u16 prefix from the §2 allocation table. `const` so it + /// composes in `const` contexts. MUST match OGAR `PortSpec::APP_PREFIX` + /// (pinned by [`tests::app_prefixes_match_ogar_allocation_table`]). + #[inline] + #[must_use] + pub const fn prefix(self) -> u16 { + match self { + AppPrefix::Core => 0x0000, + AppPrefix::OpenProject => 0x0001, + AppPrefix::Odoo => 0x0002, + AppPrefix::Woa => 0x0003, + AppPrefix::Smb => 0x0004, + AppPrefix::Healthcare => 0x0005, + AppPrefix::Redmine => 0x0007, + } + } + + /// Resolve a high-u16 prefix value back to its [`AppPrefix`]. `None` for an + /// unallocated value (`0x0006`, `0x0008`+ — reserved, costs nothing until + /// an app mints its first private class). + #[inline] + #[must_use] + pub const fn from_prefix(prefix: u16) -> Option { + match prefix { + 0x0000 => Some(AppPrefix::Core), + 0x0001 => Some(AppPrefix::OpenProject), + 0x0002 => Some(AppPrefix::Odoo), + 0x0003 => Some(AppPrefix::Woa), + 0x0004 => Some(AppPrefix::Smb), + 0x0005 => Some(AppPrefix::Healthcare), + 0x0007 => Some(AppPrefix::Redmine), + _ => None, + } + } + + /// Compose the full render `classid` for this app and a canonical concept + /// id: `(prefix << 16) | concept`. The membrane equivalent of OGAR + /// `render_classid_for::

(concept)`, reading the prefix from typed data + /// rather than a `PortSpec` generic. + #[inline] + #[must_use] + pub const fn render(self, concept: u16) -> u32 { + render_classid(self.prefix(), concept) + } +} + +/// Compose a full render `classid` from an app `prefix` (high u16) and a +/// canonical `concept` id (low u16): `(prefix << 16) | concept`. Wire-compat +/// mirror of OGAR `ogar_vocab::app::render_classid`. +/// +/// `render_classid(0x0005, 0x0901)` → `0x0005_0901` (MedCare's `patient`); the +/// core form `render_classid(0x0000, id)` equals `id` widened to `u32` +/// (additive — a bare concept IS a render classid under the core lens). +#[inline] +#[must_use] +pub const fn render_classid(prefix: u16, concept: u16) -> u32 { + ((prefix as u32) << 16) | (concept as u32) +} + +/// Compose a render `classid` from an [`AppPrefix`] and a **canonical-concept +/// string** — looks the concept up in [`CODEBOOK`], then stamps the prefix. +/// `None` if the concept is not promoted. The one-call membrane equivalent of +/// OGAR `render_classid_for::

(class_ids::CONCEPT)`: a consumer pulls the id +/// AND the prefix from ONE source instead of hardcoding `0x000N`. +/// +/// ``` +/// use lance_graph_contract::{render_classid_for_concept, AppPrefix}; +/// // MedCare patient under the Healthcare render lens — the canonical example. +/// assert_eq!(render_classid_for_concept(AppPrefix::Healthcare, "patient"), Some(0x0005_0901)); +/// assert_eq!(render_classid_for_concept(AppPrefix::Healthcare, "not_a_concept"), None); +/// ``` +#[inline] +#[must_use] +pub fn render_classid_for_concept(app: AppPrefix, concept: &str) -> Option { + canonical_concept_id(concept).map(|id| app.render(id)) +} + +/// The APP / render-prefix half of a full `classid` (`classid >> 16`). Mirror +/// of OGAR `ogar_vocab::app::app_of`. Pair with [`AppPrefix::from_prefix`] to +/// recover the typed app. +#[inline] +#[must_use] +pub const fn classid_app_prefix(classid: u32) -> u16 { + (classid >> 16) as u16 +} + +/// The canonical concept-id half of a full `classid` (`classid as u16`) — the +/// shared RBAC + ontology + cross-app identity key, identical under every +/// render prefix. Mirror of OGAR `ogar_vocab::app::concept_of`; the sibling of +/// [`classid_concept_domain`], which routes this half to its [`ConceptDomain`]. +#[inline] +#[must_use] +pub const fn classid_concept(classid: u32) -> u16 { + classid as u16 +} + /// The curated `(canonical_concept, u16)` codebook — wire-compatible mirror of /// OGAR `ogar_vocab::CODEBOOK`. Ids are stable forever (once shipped, never /// re-assigned); domain-encoded `0xDDCC`. Carries the two domains the contract @@ -304,4 +450,78 @@ mod tests { "curator alias unresolved in contract (normalize via ogar-vocab first)" ); } + + #[test] + fn app_prefixes_match_ogar_allocation_table() { + // §2 allocation table — MUST match OGAR `PortSpec::APP_PREFIX` (the + // wire). If OGAR re-allocates a prefix, update BOTH sides together. + assert_eq!(AppPrefix::Core.prefix(), 0x0000); + assert_eq!(AppPrefix::OpenProject.prefix(), 0x0001); + assert_eq!(AppPrefix::Odoo.prefix(), 0x0002); + assert_eq!(AppPrefix::Woa.prefix(), 0x0003); + assert_eq!(AppPrefix::Smb.prefix(), 0x0004); + assert_eq!(AppPrefix::Healthcare.prefix(), 0x0005); + assert_eq!(AppPrefix::Redmine.prefix(), 0x0007); + // round-trips; unallocated slots are None (reserved, cost nothing). + for app in [ + AppPrefix::Core, + AppPrefix::OpenProject, + AppPrefix::Odoo, + AppPrefix::Woa, + AppPrefix::Smb, + AppPrefix::Healthcare, + AppPrefix::Redmine, + ] { + assert_eq!(AppPrefix::from_prefix(app.prefix()), Some(app)); + } + assert_eq!(AppPrefix::from_prefix(0x0006), None); + assert_eq!(AppPrefix::from_prefix(0x0008), None); + } + + #[test] + fn render_classid_composes_decomposes_and_preserves_the_concept_half() { + // Worked examples mirrored from OGAR `ogar_vocab::app` tests. + assert_eq!(render_classid(0x0001, 0x0102), 0x0001_0102); + assert_eq!(render_classid(0x0007, 0x0102), 0x0007_0102); // Redmine twin + + // MedCare patient — the canonical worked example: 0x0005_0901. + let pat = render_classid_for_concept(AppPrefix::Healthcare, "patient").unwrap(); + assert_eq!(pat, 0x0005_0901); + assert_eq!(classid_app_prefix(pat), 0x0005); + assert_eq!(classid_concept(pat), 0x0901); + assert_eq!( + AppPrefix::from_prefix(classid_app_prefix(pat)), + Some(AppPrefix::Healthcare) + ); + // the concept half still routes to its domain under the render prefix. + assert_eq!( + canonical_concept_domain(classid_concept(pat)), + ConceptDomain::Health + ); + + // Core (hi=0x0000) is additive: a bare concept IS a render classid. + let core = render_classid(0x0000, 0x0102); + assert_eq!(core, u32::from(0x0102u16)); + assert_eq!(classid_concept(core), 0x0102); + + // The render lens never perturbs the lo-u16 concept RBAC keys on. + let op = AppPrefix::OpenProject.render(0x0103); + let rm = AppPrefix::Redmine.render(0x0103); + assert_ne!( + classid_app_prefix(op), + classid_app_prefix(rm), + "render lenses differ" + ); + assert_eq!( + classid_concept(op), + classid_concept(rm), + "concept is shared" + ); + + // Unpromoted concept → no classid (don't invent one). + assert_eq!( + render_classid_for_concept(AppPrefix::Healthcare, "nope"), + None + ); + } }