Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions crates/ogar-doc-ir/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,14 @@
//! # This crate mints nothing
//!
//! serde-only. The `document 0x080B` / `typed_field 0x080A` classid mints and
//! the persistence ActionDefs live in `ogar-vocab` / `ogar-doc` (W4), gated on
//! the doc-layer 5+3 council. This crate is pure perceptual structure with no
//! canon dependency, so it can land ahead of that ratification.
//! the persistence ActionDefs live in `ogar-vocab` (W4, not a separate
//! `ogar-doc` crate — the council landed the ActionDefs in `ogar-vocab`'s
//! own `document_actions` module per `OGAR-DOC-W4-BUILD-SPEC.md` §W4-5 §A1's
//! "executor lives in the assembly repo" ruling), 5+3-council-ratified and
//! shipped 2026-08-25 (`docs/DISCOVERY-MAP.md` `D-OGAR-DOC-LAYER`). This
//! crate stayed pure perceptual structure throughout, with no canon
//! dependency — it landed ahead of that ratification and needed no change
//! once the mints shipped.
//!
//! # Observation IR vs. the composition layer (DocIr composition grounding)
//!
Expand Down
52 changes: 51 additions & 1 deletion crates/ogar-vocab/src/capability_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,11 @@ pub fn domain_tables() -> Vec<DomainTable> {
expected_executors: crate::healthcare_actions::HEALTHCARE_EXPECTED_EXECUTORS,
entries: healthcare_entries,
},
DomainTable {
domain: "document",
expected_executors: crate::document_actions::DOCUMENT_EXPECTED_EXECUTORS,
entries: document_entries,
},
]
}

Expand Down Expand Up @@ -372,7 +377,7 @@ fn resolve_concept_row(id: u16) -> Option<(&'static str, u16)> {
mod the_canon_carries_no_palette_rows {
#[test]
fn no_0x17xx_row_reached_the_globally_mirrored_codebook() {
assert_eq!(crate::class_ids::ALL.len(), 93);
assert_eq!(crate::class_ids::ALL.len(), 94);
for (_, id) in crate::class_ids::ALL {
assert_ne!(*id >> 8, 0x17, "a 0x17XX row reached the codebook");
}
Expand Down Expand Up @@ -403,6 +408,15 @@ fn healthcare_entries() -> Vec<(String, u16)> {
entries_from_actions(&crate::healthcare_actions::healthcare_actions())
}

/// Document domain rows ([`crate::document_actions`], the paperless-rs
/// table — `OGAR-DOC-W4-BUILD-SPEC.md` §W4-4, council-ratified 2026-08-25),
/// derived through the same generic [`entries_from_actions`] path as OCR
/// and healthcare. Deliberately a SEPARATE table from `ocr_entries` — see
/// `document_actions`'s module doc for why.
fn document_entries() -> Vec<(String, u16)> {
entries_from_actions(&crate::document_actions::document_actions())
}

/// A green hot-plug resolution: the `(concept, classid)` vocab rows and the
/// sorted capability names for exactly the hot-plugged ids.
pub type HotplugActivation = (Vec<(&'static str, u16)>, Vec<String>);
Expand Down Expand Up @@ -503,6 +517,42 @@ mod hotplug_tests {
assert_eq!(caps.len(), crate::ocr_actions::OCR_ACTION_NAMES.len());
}

/// The doc-layer capstone probe (`OGAR-DOC-W4-BUILD-SPEC.md` §W4-4,
/// 5+3-council-ratified 2026-08-25): `resolve_hotplug("paperless-kv",
/// DOCUMENT_SUBJECT_CLASSIDS, covered)` GREEN, and — the property
/// `ocr_hotplug_resolves_vocab_and_actions` above does not need to
/// check, because `tesseract-ogar` never plugs `document` — that
/// `tesseract-ogar`'s OWN resolution is untouched by this new table
/// (Deviation D-1's whole point: isolation).
#[test]
fn document_hotplug_resolves_vocab_and_actions_and_does_not_contaminate_ocr() {
let (concepts, caps) = resolve_hotplug(
"paperless-kv",
crate::document_actions::DOCUMENT_SUBJECT_CLASSIDS,
crate::document_actions::DOCUMENT_ACTION_NAMES,
)
.expect("document hot-plug drifted from the authoritative table");
assert_eq!(concepts, vec![("document", crate::class_ids::DOCUMENT)]);
assert_eq!(caps.len(), 3);

// The wrong consumer against the same id bangs UnexpectedConsumer.
assert!(matches!(
resolve_hotplug(
"tesseract-ogar",
crate::document_actions::DOCUMENT_SUBJECT_CLASSIDS,
crate::document_actions::DOCUMENT_ACTION_NAMES,
),
Err(HotplugDrift::UnexpectedConsumer(_))
));

// tesseract-ogar's OWN resolution is byte-identical to before this
// table existed — the isolation property Deviation D-1 exists for.
let (ocr_concepts, ocr_caps) =
resolve_hotplug("tesseract-ogar", OCR_IDS, OCR_COVERED).expect("green");
assert_eq!(ocr_concepts.len(), OCR_IDS.len());
assert_eq!(ocr_caps.len(), crate::ocr_actions::OCR_ACTION_NAMES.len());
}

/// The parity-plan P3 probe: `resolve_hotplug("medcare-rs",
/// HEALTHCARE_SUBJECT_CLASSIDS, covered)` GREEN (KILL condition was
/// `NoCapabilitiesFor`/`Uncovered`). The Health classids that banged
Expand Down
217 changes: 217 additions & 0 deletions crates/ogar-vocab/src/document_actions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
//! Document persistence capability surface — the **paperless-rs
//! authoritative action table** for the `document` (`0x080B`) subtree.
//!
//! Declares the three capabilities `OGAR-DOC-W4-BUILD-SPEC.md` §W4-4
//! specifies (`persist_document` / `read_document` / `reconstruct_document`),
//! each targeting the minted `document` concept
//! ([`crate::class_ids::DOCUMENT`]) as its `object_class`. It registers in
//! [`crate::capability_registry::domain_tables`] through the generic
//! [`crate::capability_registry::entries_from_actions`] derive, exactly as
//! [`crate::healthcare_actions`] and [`crate::ocr_actions`] do.
//!
//! # Why a SEPARATE table, not a growth of `ocr_actions.rs`
//!
//! `OGAR-DOC-W4-BUILD-SPEC.md` §W4-4's literal text says these three rows
//! belong in "`ogar-vocab/src/ocr_actions.rs`, same table as the shipped 14
//! caps." A council review of that placement (5+3 council, 2026-08-25)
//! found it would silently couple two unrelated consumers:
//! [`crate::capability_registry::resolve_hotplug`] gates per **contributing
//! table**, not globally
//! (`capability_registry.rs::resolve_hotplug`) — so if these three rows
//! physically lived in `ocr_actions.rs` and `paperless-kv` (a non-OCR
//! consumer) plugged `document`, `ocr_actions::OCR_EXPECTED_EXECUTORS`
//! would have to grow to include `paperless-kv`, permanently entangling
//! `tesseract-ogar`'s and `paperless-kv`'s hot-plug resolutions through one
//! shared list. `healthcare_actions.rs` is this crate's own precedent for
//! exactly this situation — "a *private* consumer... registers here in
//! every build" via a table isolated to its own executor list
//! ([`crate::capability_registry::domain_tables`]'s own doc comment) — so
//! this module follows that shape instead. `tesseract-ogar`'s existing
//! `HOT_PLUG` is unaffected: this table never contributes to a plug that
//! doesn't include `document`.
//!
//! # Facts only — the executor body is NOT here
//!
//! Per §W4-4's own rule: *"Facts only: HOW nodes are written (mailbox /
//! Lance) is the executor seam's business."* These `ActionDef`s carry only
//! static identity/signature/kausal fields — no storage state, no write
//! logic. The consumer (`paperless-kv`) declares a [`crate::hotplug`]-style
//! [`lance_graph_contract` (via the consumer, not this crate)] activation
//! against them; the actual `persist_document` implementation (walking a
//! `DocIr` into GUID-keyed SoA nodes, `DedupIndex`-backed) is explicitly
//! future work, not part of this table.
//!
//! # `typed_field` is minted, not used as a subject here
//!
//! [`crate::class_ids::TYPED_FIELD`] (`0x080A`) mints alongside `document`
//! in this same council landing, but none of these three ActionDefs use it
//! as a subject — `typed_field` is the INTERNAL per-field decomposition a
//! future `persist_document` body would write, not part of this table's
//! public capability surface.

use crate::{ActionDef, ActionSubject, KausalSpec};

/// Every document capability name, in table order — the `const`-evaluable
/// fingerprint of [`document_actions`] (same role as
/// [`crate::ocr_actions::OCR_ACTION_NAMES`] /
/// [`crate::healthcare_actions::HEALTHCARE_ACTION_NAMES`]).
pub const DOCUMENT_ACTION_NAMES: &[&str] =
&["persist_document", "read_document", "reconstruct_document"];

const _: () = assert!(DOCUMENT_ACTION_NAMES.len() == 3);

/// Build one [`ActionDef`] for a document capability. `reads`/`writes`
/// carry the mandatory + optional field names from
/// `OGAR-DOC-W4-BUILD-SPEC.md` §W4-4's param table (name-level effect
/// facts, matching [`crate::ocr_actions::ocr_action_def`]'s convention —
/// richer than [`crate::healthcare_actions`]'s deliberately-empty rows,
/// since §W4-4 explicitly enumerates the signature).
fn document_action_def(capability: &'static str, reads: &[&str], writes: &[&str]) -> ActionDef {
let object_class = "ogit-ocr/document".to_string();
let identity = format!("{object_class}::action_def::{capability}");
ActionDef {
identity,
predicate: capability.to_owned(),
object_class,
default_subject: ActionSubject::System,
// Every capability here is invoked directly by a caller (the
// paperless-kv ingestion pipeline, an RPC edge) — no OGAR-side
// precondition to guard on, matching `KausalSpec::External`'s doc
// and the identical convention `ocr_actions::ocr_action_def` uses.
kausal: Some(KausalSpec::External),
reads: reads.iter().map(|s| (*s).to_owned()).collect(),
writes: writes.iter().map(|s| (*s).to_owned()).collect(),
..ActionDef::default()
}
}

/// The paperless-rs document-persistence capability surface — the
/// **authoritative OGAR action table** for the `document` subtree. One
/// [`ActionDef`] per capability, in [`DOCUMENT_ACTION_NAMES`] order,
/// subject = `document` (`0x080B`) throughout.
///
/// | capability | mandatory | optional | produces |
/// |---|---|---|---|
/// | `persist_document` | `doc_ir, raw_sha256, raw_kv_key` | — | `document_guid` |
/// | `read_document` | `document_guid` | — | `doc_ir` |
/// | `reconstruct_document` | `document_guid` | `template` | `pdf_bytes` |
#[must_use]
pub fn document_actions() -> Vec<ActionDef> {
vec![
document_action_def(
"persist_document",
&["doc_ir", "raw_sha256", "raw_kv_key"],
&["document_guid"],
),
document_action_def("read_document", &["document_guid"], &["doc_ir"]),
document_action_def(
"reconstruct_document",
&["document_guid", "template"],
&["pdf_bytes"],
),
]
}

/// The executors the authority EXPECTS to register against this table.
/// `paperless-kv` is the Rust consumer (`OGAR-DOC-W4-BUILD-SPEC.md` §W4-5
/// §A1: the executor lives in the assembly repo, not a new `ogar-doc`
/// crate — council-ratified 2026-08-25).
pub const DOCUMENT_EXPECTED_EXECUTORS: &[&str] = &["paperless-kv"];

/// The distinct subject classids this table binds (canon-high concept
/// ids). A registering consumer must activate exactly this set.
pub const DOCUMENT_SUBJECT_CLASSIDS: &[u16] = &[crate::class_ids::DOCUMENT];

#[cfg(test)]
mod tests {
use super::*;
use crate::{canonical_concept_domain, class_ids};
use std::collections::BTreeSet;

#[test]
fn table_length_matches_const_name_fingerprint() {
let actions = document_actions();
assert_eq!(actions.len(), DOCUMENT_ACTION_NAMES.len());
for (def, name) in actions.iter().zip(DOCUMENT_ACTION_NAMES) {
assert_eq!(&def.predicate, name);
}
}

#[test]
fn capability_names_are_unique() {
let actions = document_actions();
let names: BTreeSet<&str> = actions.iter().map(|s| s.predicate.as_str()).collect();
assert_eq!(names.len(), actions.len());
}

#[test]
fn every_action_declares_external_kausal() {
for def in document_actions() {
assert_eq!(def.kausal, Some(KausalSpec::External));
}
}

/// Every document action's subject resolves to the minted `document`
/// (`0x080B`) concept, in the OCR domain (per its `class_ids::ALL`
/// placement) — the fuse that catches a renamed or unminted concept
/// before a consumer trusts this table.
#[test]
fn subjects_resolve_to_the_minted_document_concept() {
for def in document_actions() {
assert_eq!(def.object_class, "ogit-ocr/document");
let entry = class_ids::ALL
.iter()
.find(|(name, _)| *name == "document")
.expect("document minted");
assert_eq!(entry.1, class_ids::DOCUMENT);
assert_eq!(canonical_concept_domain(entry.1), crate::ConceptDomain::Ocr);
}
}

/// `reconstruct_document`'s mandatory reads must be a SUBSET of
/// `read_document`'s + the produced `document_guid` chain — cannot
/// need less than resolving a document requires (mirrors
/// `ocr_actions`'s `recognize_document_reads_cover_the_word_stage`
/// composition pin).
#[test]
fn reconstruct_document_requires_a_document_guid_like_read_document() {
let actions = document_actions();
let get = |name: &str| actions.iter().find(|d| d.predicate == name).unwrap();
assert!(
get("read_document")
.reads
.contains(&"document_guid".to_string())
);
assert!(
get("reconstruct_document")
.reads
.contains(&"document_guid".to_string())
);
}

/// This table's own registration roundtrip, mirroring
/// `ocr_actions::verify_ocr_registration` / the healthcare table's
/// equivalent — a convenience wrapper a consumer's own test can call.
#[test]
fn document_hot_plug_resolves_via_the_generic_registry() {
let (concepts, caps) = crate::capability_registry::resolve_hotplug(
"paperless-kv",
DOCUMENT_SUBJECT_CLASSIDS,
DOCUMENT_ACTION_NAMES,
)
.expect("document hot-plug drifted from the authoritative table");
assert_eq!(concepts, vec![("document", class_ids::DOCUMENT)]);
assert_eq!(caps.len(), 3);

// Falsifiability: a different consumer name is rejected, not
// silently accepted — the can-it-FAIL half.
assert!(matches!(
crate::capability_registry::resolve_hotplug(
"tesseract-ogar",
DOCUMENT_SUBJECT_CLASSIDS,
DOCUMENT_ACTION_NAMES,
),
Err(crate::capability_registry::HotplugDrift::UnexpectedConsumer(_))
));
}
}
Loading
Loading