From 44445934361381ab74051b1d26321be8ab9aedcb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 23:28:29 +0000 Subject: [PATCH] feat(ogar-vocab): mint directory_user, directory_group, group_membership, license_assignment (0x0B06-0x0B09) Four IAM-domain concepts above email: a directory user and group as observed (AD / Entra), one user-to-group membership edge, and one observed license SKU on one user. They are what directory reads bind to and what RBAC grants and field masks on directory data key on. Each gets a promoted Class and an ogar-class-view registry row; the Auth domain count moves from 5 to 9. ogar-flow-m365 binds its directory operations to them (ConceptBinding::Minted) and declares its flow body concept 0x1718, a palette slot above blockly-rs 0x1717 kept crate-local like every palette, with vocab::plug_into for VocabularyRegistry. The lance-graph-contract ogar_codebook mirror rows land in the paired lance-graph PR. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMM44PgMCtYoqx6XYnh --- crates/ogar-class-view/src/lib.rs | 8 +++ crates/ogar-flow-m365/Cargo.toml | 5 +- crates/ogar-flow-m365/README.md | 46 +++++++------ crates/ogar-flow-m365/src/adjacency.rs | 39 +++++------ crates/ogar-flow-m365/src/tests.rs | 93 ++++++++++++++++++++++++-- crates/ogar-flow-m365/src/vocab.rs | 27 +++++++- crates/ogar-vocab/src/lib.rs | 82 ++++++++++++++++++++++- 7 files changed, 242 insertions(+), 58 deletions(-) diff --git a/crates/ogar-class-view/src/lib.rs b/crates/ogar-class-view/src/lib.rs index 0fdeb12..f9e3eed 100644 --- a/crates/ogar-class-view/src/lib.rs +++ b/crates/ogar-class-view/src/lib.rs @@ -101,10 +101,13 @@ use ogar_vocab::{ crm_ticket, currency_policy, diagnosis, + directory_group, + directory_user, document, email, examination, external_practice, + group_membership, hr_department, hr_employee, hr_employment_contract, @@ -113,6 +116,7 @@ use ogar_vocab::{ joint, knowledge_item, lab_value, + license_assignment, machine_memory_map, mars_application, mars_machine, @@ -300,6 +304,10 @@ fn all_canonical_classes() -> Vec<(&'static str, Class)> { ("auth_zanzibar", auth_zanzibar()), ("auth_ory_keto", auth_ory_keto()), ("email", email()), + ("directory_user", directory_user()), + ("directory_group", directory_group()), + ("group_membership", group_membership()), + ("license_assignment", license_assignment()), // ── 0x0DXX — HR cluster (closes the final 4-of-11 odoo-rs #14 gap) ── ("hr_employee", hr_employee()), ("hr_department", hr_department()), diff --git a/crates/ogar-flow-m365/Cargo.toml b/crates/ogar-flow-m365/Cargo.toml index c1eb46e..8daad4e 100644 --- a/crates/ogar-flow-m365/Cargo.toml +++ b/crates/ogar-flow-m365/Cargo.toml @@ -18,12 +18,11 @@ fixture = [] [dependencies] ogar-loco = { path = "../ogar-loco" } ogar-auth = { path = "../ogar-auth" } -ogar-dir-core = { path = "../ogar-dir-core" } +# Zero-dep: the canonical concept ids the operations bind to. +ogar-vocab = { path = "../ogar-vocab" } # Same coordinate the eight sibling OGAR crates and ogar-loco itself use. lance-graph-contract = { git = "https://github.com/AdaWorldAPI/lance-graph", branch = "main" } -[dev-dependencies] -ogar-vocab = { path = "../ogar-vocab" } [[example]] name = "john_doe_101" diff --git a/crates/ogar-flow-m365/README.md b/crates/ogar-flow-m365/README.md index eddb271..262dcce 100644 --- a/crates/ogar-flow-m365/README.md +++ b/crates/ogar-flow-m365/README.md @@ -107,41 +107,43 @@ start with one source and end in `Export-Report`. | Directory observations | **mocked**: `fixture::FixtureDirectory` (synthetic) | | Report authority | **mocked**: `fixture::FixtureAuthority`, a test double, not security | | Assertion verification | **mocked**: `fixture::FixtureVerifier`, driven through the real `establish` | -| Production authority via `ogar-rbac` (`OgarRbac::authorize_user`, field masks) | **deferred, gated** (below) | -| Observation adapter over `ogar-az` / Microsoft Graph | deferred (PR-FLOW-02) | -| Registering the vocabulary in a `VocabularyRegistry` under a body classid | **deferred, gated** (below) | +| Production authority via `ogar-rbac` (`OgarRbac::authorize_user`, field masks) | deferred (PR-FLOW-02); the classids it keys on now exist (below) | +| Observation adapter over `lance-graph-dir-sim` / `ogar-az` | deferred (PR-FLOW-02) | +| Registering the vocabulary in a `VocabularyRegistry` | implemented: `vocab::plug_into`, body concept `0x1718` | | CSV / other export formats, `-like` / `-match`, `ForEach-Object`, sorting | not implemented | -### The two gates - -Both are the same missing piece: **no directory-user, license-assignment, -group-membership or report concept is minted in `ogar-vocab`.** This crate -mints nothing. - -- **RBAC.** `OgarRbac::authorize_user(identity, class, op)` and its field - masks are keyed by classid. Without a minted class for a directory user (and - for license and membership facts), the production authority has nothing to - be asked about. `ReportAuthority` is the seam it will implement. -- **Registry.** `VocabularyRegistry::plug` selects a vocabulary by the body - node's concept id. The flow vocabulary is validated and run directly; it is - not plugged into a registry, because that needs a body concept id (the - `ogar-ro` `0x0306` and `ogar-dismech` `0x0333` precedent), which is an - operator decision. +### Classids Every operation records its binding (`adjacency::ConceptBinding`): -`DirSchema` (the `ogar-dir-core` MS Graph user schema), `Generic`, or -`Unminted("…")` naming what is missing. + +| operations | binding | +|---|---| +| `query_user`, `update_user`, `remove_user` | `Minted(0x0B06)` `directory_user` | +| `read_membership`, `add_group_member` | `Minted(0x0B08)` `group_membership` | +| `read_license_evidence`, `assign_license` | `Minted(0x0B09)` `license_assignment` | +| `filter_rows`, `select_fields`, `evaluate_expression` | `Generic` | +| `export_report` | `Unminted`: report execution belongs to `lance-graph-report` | + +`directory_user` (`0x0B06`), `directory_group` (`0x0B07`), `group_membership` +(`0x0B08`) and `license_assignment` (`0x0B09`) are minted in `ogar-vocab`'s +IAM domain, above `email`. They are what `OgarRbac` grants and field masks on +directory data key on, so `ReportAuthority` can be backed by it. + +The flow body's concept id, `0x1718`, is a palette slot in `ogar-loco`'s +`0x17XX` domain, above `blockly-rs` (`0x1717`). Like every palette slot it is +declared in this crate, not in `ogar-vocab`'s mirrored codebook. +`vocab::plug_into` registers the vocabulary under it. ## The litmus test `Get-MgUser` was added as a friendly label without changing anything in `ogar-loco`: no new opcode, no change to the call ABI, the interpreter, the conformance rules or `DOMAIN_FLOOR`. The only LOCO primitive missing for -production is outside LOCO: the concept ids above. +production was outside LOCO: the concept ids above. ## Falsifiers -`cargo test -p ogar-flow-m365` runs 29 tests over synthetic data, F1–F10: +`cargo test -p ogar-flow-m365` runs 31 tests over synthetic data, F1–F10: resolution and refusal (F1), alias equivalence (F2), no new concept and shared-core comparison reuse (F3), read-only enforcement including hand-built bodies (F4), actor isolation: no scope, missing capability, diff --git a/crates/ogar-flow-m365/src/adjacency.rs b/crates/ogar-flow-m365/src/adjacency.rs index 29556f6..1b4ca9a 100644 --- a/crates/ogar-flow-m365/src/adjacency.rs +++ b/crates/ogar-flow-m365/src/adjacency.rs @@ -16,8 +16,8 @@ //! String matching happens here, once, at the compile boundary. Execution //! sees only [`FnIndex`] bytes. -use ogar_dir_core::{SchemaFamily, SchemaId}; use ogar_loco::FnIndex; +use ogar_vocab::class_ids; use crate::vocab; @@ -105,19 +105,18 @@ pub enum Backend { /// The semantic target an operation is bound to. /// -/// No variant carries a classid, because no directory-user, license, -/// group-membership or report concept is minted in `ogar-vocab` today, and -/// this crate does not mint concepts. Binding these to classids is the gate -/// for the production RBAC integration (README § Deferred). +/// A directory operation is bound to the `ogar-vocab` concept its rows are +/// (`directory_user`, `group_membership`, `license_assignment`), which is +/// what RBAC grants and field masks on that data key on. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ConceptBinding { /// A pipeline operator over any population (filter, projection). It has /// no concept of its own. Generic, - /// Bound to an existing typed directory schema in `ogar-dir-core`. - DirSchema(SchemaId), - /// The operation has no canonical OGAR concept yet. The string names the - /// missing piece so the gap is visible at the call site. + /// Bound to a concept minted in `ogar-vocab` (the hi-u16 concept id). + Minted(u16), + /// The operation has no OGAR concept. The string names why, so the gap is + /// visible at the call site. Unminted(&'static str), } @@ -153,12 +152,6 @@ impl Operation { } } -/// The Graph `user` schema `ogar-az` encodes against. -const MS_GRAPH_USER: SchemaId = SchemaId { - family: SchemaFamily::MsGraph, - version: 2, -}; - /// Every operation, indexed by [`OperationId`]. pub const OPERATIONS: &[Operation] = &[ // 0 @@ -170,7 +163,7 @@ pub const OPERATIONS: &[Operation] = &[ input: FlowShape::Nothing, output: FlowShape::Population, backend: Backend::Loco, - binding: ConceptBinding::DirSchema(MS_GRAPH_USER), + binding: ConceptBinding::Minted(class_ids::DIRECTORY_USER), }, // 1 Operation { @@ -181,7 +174,7 @@ pub const OPERATIONS: &[Operation] = &[ input: FlowShape::Population, output: FlowShape::Population, backend: Backend::Loco, - binding: ConceptBinding::Unminted("no license-assignment concept in ogar-vocab"), + binding: ConceptBinding::Minted(class_ids::LICENSE_ASSIGNMENT), }, // 2 Operation { @@ -192,7 +185,7 @@ pub const OPERATIONS: &[Operation] = &[ input: FlowShape::Population, output: FlowShape::Population, backend: Backend::Loco, - binding: ConceptBinding::Unminted("no directory group-membership concept in ogar-vocab"), + binding: ConceptBinding::Minted(class_ids::GROUP_MEMBERSHIP), }, // 3 Operation { @@ -226,7 +219,7 @@ pub const OPERATIONS: &[Operation] = &[ output: FlowShape::Report, backend: Backend::Loco, binding: ConceptBinding::Unminted( - "report execution is lance-graph-report's; no OGAR concept", + "report execution belongs to lance-graph-report; no OGAR concept", ), }, // 6 — known, refused. @@ -238,7 +231,7 @@ pub const OPERATIONS: &[Operation] = &[ input: FlowShape::Population, output: FlowShape::Nothing, backend: Backend::None, - binding: ConceptBinding::DirSchema(MS_GRAPH_USER), + binding: ConceptBinding::Minted(class_ids::DIRECTORY_USER), }, // 7 Operation { @@ -249,7 +242,7 @@ pub const OPERATIONS: &[Operation] = &[ input: FlowShape::Population, output: FlowShape::Nothing, backend: Backend::None, - binding: ConceptBinding::DirSchema(MS_GRAPH_USER), + binding: ConceptBinding::Minted(class_ids::DIRECTORY_USER), }, // 8 Operation { @@ -260,7 +253,7 @@ pub const OPERATIONS: &[Operation] = &[ input: FlowShape::Population, output: FlowShape::Nothing, backend: Backend::None, - binding: ConceptBinding::Unminted("no directory group-membership concept in ogar-vocab"), + binding: ConceptBinding::Minted(class_ids::GROUP_MEMBERSHIP), }, // 9 Operation { @@ -271,7 +264,7 @@ pub const OPERATIONS: &[Operation] = &[ input: FlowShape::Population, output: FlowShape::Nothing, backend: Backend::None, - binding: ConceptBinding::Unminted("no license-assignment concept in ogar-vocab"), + binding: ConceptBinding::Minted(class_ids::LICENSE_ASSIGNMENT), }, // 10 Operation { diff --git a/crates/ogar-flow-m365/src/tests.rs b/crates/ogar-flow-m365/src/tests.rs index c9fafa1..fd9817e 100644 --- a/crates/ogar-flow-m365/src/tests.rs +++ b/crates/ogar-flow-m365/src/tests.rs @@ -252,12 +252,21 @@ fn f3_no_classid_is_minted_and_no_label_is_a_concept() { "{}", op.name ); - // Bindings name an existing schema, the generic operator, or the - // missing concept, never a classid of their own. - assert!(matches!( - op.binding, - ConceptBinding::Generic | ConceptBinding::DirSchema(_) | ConceptBinding::Unminted(_) - )); + // A minted binding names a concept ogar-vocab actually mints, in the + // IAM domain; nothing here invents a classid of its own. + if let ConceptBinding::Minted(id) = op.binding { + assert!( + ogar_vocab::canonical_concept_name(id).is_some(), + "{}", + op.name + ); + assert_eq!( + ogar_vocab::canonical_concept_domain(id), + ogar_vocab::ConceptDomain::Auth, + "{}", + op.name + ); + } } for l in LABELS { let normal = l.text.to_ascii_lowercase().replace('-', "_"); @@ -823,3 +832,75 @@ fn a_run_that_leaves_values_on_the_stack_returns_no_report() { Err(RunError::Dialect(FlowError::NoReport)) ); } + +#[test] +fn f3_directory_operations_bind_to_the_minted_directory_concepts() { + use ogar_vocab::class_ids; + let binding = |name: &str| { + OPERATIONS + .iter() + .find(|o| o.name == name) + .map(|o| o.binding) + .unwrap() + }; + for (op, id, concept) in [ + ("query_user", class_ids::DIRECTORY_USER, "directory_user"), + ( + "read_license_evidence", + class_ids::LICENSE_ASSIGNMENT, + "license_assignment", + ), + ( + "read_membership", + class_ids::GROUP_MEMBERSHIP, + "group_membership", + ), + ("update_user", class_ids::DIRECTORY_USER, "directory_user"), + ( + "assign_license", + class_ids::LICENSE_ASSIGNMENT, + "license_assignment", + ), + ( + "add_group_member", + class_ids::GROUP_MEMBERSHIP, + "group_membership", + ), + ] { + assert_eq!(binding(op), ConceptBinding::Minted(id), "{op}"); + assert_eq!(ogar_vocab::canonical_concept_name(id), Some(concept)); + } + // Pipeline operators have no concept, and report execution has none here. + assert_eq!(binding("filter_rows"), ConceptBinding::Generic); + assert_eq!(binding("select_fields"), ConceptBinding::Generic); + assert!(matches!( + binding("export_report"), + ConceptBinding::Unminted(_) + )); +} + +#[test] +fn f9_the_flow_vocabulary_plugs_into_a_registry_under_its_body_classid() { + let mut hub = ogar_loco::VocabularyRegistry::new(); + vocab::plug_into(&mut hub).unwrap(); + let t = hub + .resolve_classid(vocab::flow_body_render_classid(0x0001)) + .expect("0x1718 routes to the flow vocabulary"); + assert_eq!(t, vocab::checked().table()); + assert_eq!(t.name(vocab::GET_USER), Some("get_user")); + assert_eq!( + vocab::plug_into(&mut hub), + Err(ogar_loco::RegistryError::ConceptTaken { + concept_id: vocab::FLOW_BODY_CONCEPT_ID + }) + ); + // A palette slot: in LOCO's domain, and absent from the global codebook. + assert_eq!( + ogar_vocab::canonical_concept_domain(vocab::FLOW_BODY_CONCEPT_ID), + ogar_vocab::ConceptDomain::Blocks + ); + assert_eq!( + ogar_vocab::canonical_concept_name(vocab::FLOW_BODY_CONCEPT_ID), + None + ); +} diff --git a/crates/ogar-flow-m365/src/vocab.rs b/crates/ogar-flow-m365/src/vocab.rs index df65b7a..8331b80 100644 --- a/crates/ogar-flow-m365/src/vocab.rs +++ b/crates/ogar-flow-m365/src/vocab.rs @@ -21,11 +21,36 @@ //! second operator table. use lance_graph_contract::class_view::FieldMask; -use ogar_loco::{FnIndex, LaneShape, ValueCodebook, Vocabulary}; +use ogar_loco::{FnIndex, LaneShape, RegistryError, ValueCodebook, Vocabulary, VocabularyRegistry}; /// Every flow body's lane carving. pub const FLOW_SHAPE: LaneShape = LaneShape::Quads; +/// `0x1718` — the concept id of a flow body node: the classid a +/// [`VocabularyRegistry`] routes to [`FlowVocabulary`]. +/// +/// A palette slot in `ogar-loco`'s `0x17XX` domain, seated above +/// `blockly-rs` (`0x1717`) as `ogar-loco` reserves `0x1717`+ for consumer +/// palettes. Like `0x1717`, it lives in this crate, not in `ogar-vocab`'s +/// globally mirrored codebook (a palette is a frontend's private reading). +pub const FLOW_BODY_CONCEPT_ID: u16 = 0x1718; + +/// The full render classid of a flow body under `app_prefix`. +#[must_use] +pub const fn flow_body_render_classid(app_prefix: u16) -> u32 { + ((FLOW_BODY_CONCEPT_ID as u32) << 16) | (app_prefix as u32) +} + +/// Register [`FlowVocabulary`] under [`FLOW_BODY_CONCEPT_ID`]. +/// +/// # Errors +/// +/// [`RegistryError::ConceptTaken`] when the registry already has a vocabulary +/// for `0x1718`. +pub fn plug_into(registry: &mut VocabularyRegistry) -> Result<(), RegistryError> { + registry.plug(FLOW_BODY_CONCEPT_ID, &checked()) +} + /// `get_user`: look up one directory user. pub const GET_USER: FnIndex = FnIndex(0x90); /// `get_license_evidence`: attach observed license facts. diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index 4186ffa..fb873a0 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -1414,6 +1414,16 @@ const CODEBOOK: &[(&str, u16)] = &[ // directory. Mailboxes, folders, flags and threads are columns of an // email row, not concepts of their own. Consumed by spear (`SpearPort`). ("email", 0x0B05), + // Directory objects as observed (AD / Entra / Exchange), above `email`. + // `directory_user` / `directory_group` are the users and groups of + // `ogar-dir-sim`'s two populations; `group_membership` is one + // user→group edge; `license_assignment` is one observed license SKU on + // one user. They are what directory reads (ogar-flow-m365) bind to and + // what RBAC grants and field masks on directory data key on. + ("directory_user", 0x0B06), + ("directory_group", 0x0B07), + ("group_membership", 0x0B08), + ("license_assignment", 0x0B09), // ── 0x0CXX — Automation domain (the HIRO IT-automation stack) ── // One domain spanning the MARS structural CMDB (`ogit.MARS:` — // Application/Resource/Software/Machine, the A→R→S→M dependsOn backbone) @@ -2335,6 +2345,17 @@ pub mod class_ids { /// `email` (`0x0B05`) — one email message. OGIT `ogit:Email`. In the IAM /// domain, above the AuthStore profiles. pub const EMAIL: u16 = 0x0B05; + /// `directory_user` (`0x0B06`) — one directory user as observed + /// (AD / Entra), keyed by its object GUID. + pub const DIRECTORY_USER: u16 = 0x0B06; + /// `directory_group` (`0x0B07`) — one directory group (security or + /// distribution), keyed by its object GUID. + pub const DIRECTORY_GROUP: u16 = 0x0B07; + /// `group_membership` (`0x0B08`) — one user→group membership edge. + pub const GROUP_MEMBERSHIP: u16 = 0x0B08; + /// `license_assignment` (`0x0B09`) — one observed license SKU assigned + /// to one directory user. + pub const LICENSE_ASSIGNMENT: u16 = 0x0B09; // ── 0x0DXX — HR domain (employment / org / contracts) ── @@ -2557,6 +2578,10 @@ pub mod class_ids { ("auth_zanzibar", AUTH_ZANZIBAR), ("auth_ory_keto", AUTH_ORY_KETO), ("email", EMAIL), + ("directory_user", DIRECTORY_USER), + ("directory_group", DIRECTORY_GROUP), + ("group_membership", GROUP_MEMBERSHIP), + ("license_assignment", LICENSE_ASSIGNMENT), // 0x0DXX — HR (employment / org / contracts; closes the final // 4-of-11 cross-axis gap from odoo-rs PR #14) ("hr_employee", HR_EMPLOYEE), @@ -3508,6 +3533,10 @@ pub fn all_promoted_classes() -> Vec { auth_zanzibar(), auth_ory_keto(), email(), + directory_user(), + directory_group(), + group_membership(), + license_assignment(), // 0x0DXX — HR arm hr_employee(), hr_department(), @@ -5330,6 +5359,48 @@ pub fn email() -> Class { c } +// ───────────────────────────────────────────────────────────────────── +// Directory objects (`0x0B06`–`0x0B09`, IAM domain). Identity only; the +// columns live in the consumer's ClassView (lance-graph-dir-sim's +// populations, ogar-flow-m365's report fields). +// ───────────────────────────────────────────────────────────────────── + +/// `directory_user` (`0x0B06`) — one directory user as observed (AD / Entra). +#[must_use] +pub fn directory_user() -> Class { + let mut c = Class::new("DirectoryUser"); + c.language = Language::Unknown; + c.canonical_concept = Some("directory_user".to_string()); + c +} + +/// `directory_group` (`0x0B07`) — one directory group (security or distribution). +#[must_use] +pub fn directory_group() -> Class { + let mut c = Class::new("DirectoryGroup"); + c.language = Language::Unknown; + c.canonical_concept = Some("directory_group".to_string()); + c +} + +/// `group_membership` (`0x0B08`) — one user→group membership edge. +#[must_use] +pub fn group_membership() -> Class { + let mut c = Class::new("GroupMembership"); + c.language = Language::Unknown; + c.canonical_concept = Some("group_membership".to_string()); + c +} + +/// `license_assignment` (`0x0B09`) — one observed license SKU on one directory user. +#[must_use] +pub fn license_assignment() -> Class { + let mut c = Class::new("LicenseAssignment"); + c.language = Language::Unknown; + c.canonical_concept = Some("license_assignment".to_string()); + c +} + // ───────────────────────────────────────────────────────────────────── // 0xC6XX — MMIO hardware-platform builders (C64 = founding tenant). // Kind-level containers only: registers/opcodes are content rows. @@ -6441,6 +6512,10 @@ mod tests { ("auth_zanzibar", 0x0B03), ("auth_ory_keto", 0x0B04), ("email", 0x0B05), + ("directory_user", 0x0B06), + ("directory_group", 0x0B07), + ("group_membership", 0x0B08), + ("license_assignment", 0x0B09), ] { assert_eq!( canonical_concept_id(concept), @@ -6449,8 +6524,9 @@ mod tests { ); assert_eq!(canonical_concept_domain(id), ConceptDomain::Auth); } - // The four preminted profiles plus `email` are the Auth block today. - assert_eq!(concepts_in_domain(ConceptDomain::Auth).count(), 5); + // The four preminted profiles, `email`, and the four directory + // concepts are the Auth block today. + assert_eq!(concepts_in_domain(ConceptDomain::Auth).count(), 9); } #[test] @@ -6602,7 +6678,7 @@ mod tests { assert_eq!(concepts_in_domain(ConceptDomain::Commerce).count(), 11); assert_eq!(concepts_in_domain(ConceptDomain::ProjectMgmt).count(), 26); assert_eq!(concepts_in_domain(ConceptDomain::Anatomy).count(), 4); - assert_eq!(concepts_in_domain(ConceptDomain::Auth).count(), 5); + assert_eq!(concepts_in_domain(ConceptDomain::Auth).count(), 9); assert_eq!(concepts_in_domain(ConceptDomain::Automation).count(), 9); // Every yielded Automation id really is in-domain (0x0CXX). let automation: Vec<&str> = concepts_in_domain(ConceptDomain::Automation)