Skip to content

Schema library v2 - #75

Open
BaptisteGi wants to merge 37 commits into
mainfrom
bgi-schema-library-v2
Open

Schema library v2#75
BaptisteGi wants to merge 37 commits into
mainfrom
bgi-schema-library-v2

Conversation

@BaptisteGi

@BaptisteGi BaptisteGi commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Schema Library v2.0

Version 2.0 is a full pass over the base DCIM and IPAM schemas and most extensions, rather than an incremental update. It combines two kinds of work: closing data-modeling gaps found in an audit of the previous schema — VRF route targets that could only hold one target, interfaces with no valid default MTU, two competing topology and location models, inconsistent use of Dropdown versus plain-text enums, typos in published attribute names, among others — and adding capabilities the library didn't have before, such as tracking swappable device modules and power supplies, assigning IP aggregates to a registry, and scoping tenancy to devices and prefixes. Because so much of the base and extension schemas changed shape as part of that cleanup, v2.0 is not compatible with schemas or data built on v1.x: loading it as-is over an existing v1.x deployment will fail or silently drop data on the attributes and relationships listed below, so plan a migration rather than an in-place upgrade.

Warning

Breaking changes in this release

This release changes attribute and relationship names, types, and cardinalities across base/dcim.yml, base/ipam.yml, base/location.yml, and most extensions. Loading v2.0 over data created with v1.x schemas requires migrating that data first.

Highlights

  • Track modules and power supplies as inventory on a device. The previous extensions/modules shipped no ready-to-use node types, only a generic to build on. extensions/device_module now ships a working Module/ModuleType pair tied to a DcimModuleBay slot, and extensions/device_psu_module adds a PSU flavor with wattage and hot-swap attributes. See Track modules and power supplies as inventory on a device.
  • Assign top-level IP space to a registry. There was no way to record which RIR (or private authority) assigned a given block of address space. The new extensions/aggregate extension adds an Aggregate node tied to an RIR node. See Assign IP aggregates to a registry.
  • Model racks and standalone sites without a full location hierarchy. Racks were only available nested inside the four-tier location_minimal hierarchy. extensions/rack and extensions/location_site split racks and sites into their own extensions, so either can be loaded independently. See Model racks and standalone sites independently of a full location hierarchy.
  • Scope tenancy to devices, prefixes, and locations. The experimental tenancy schema only linked a tenant to buildings and circuits. extensions/tenancy (promoted out of experimental) wires a Tenant directly to devices, prefixes, IP addresses, and hosted locations. See Scope tenancy to devices, prefixes, and hosted locations.
  • Model VRFs, interfaces, and VLANs closer to industry standards. Route targets, interface MTU/role/status choices, and VLAN/QinQ modeling all had known modeling errors or inconsistencies. See Align VRFs, interfaces, and VLANs with industry standards.

Track modules and power supplies as inventory on a device

Devices with swappable hardware — fan trays, line cards, power supplies — can now be modeled as installed modules rather than static device attributes.

What changed:

  • extensions/device_module (replacing extensions/modules) adds a DcimModuleBay node representing a physical slot on a device, and a ready-to-use Module/ModuleType pair that installs into a bay. A module can only be tracked once it is installed in a bay; spares awaiting installation are not modeled.
  • extensions/device_psu_module (new, depends on extensions/device_module) adds a PSU-specific module type with wattage (Number) and hotswappable (Boolean) attributes.
  • extensions/modules_linecards and extensions/modules_routing_engine now depend on extensions/device_module instead of the removed extensions/modules.
  • extensions/patch_panel no longer defines its own DcimPatchPanelModule node; a patch panel's modules are now tracked through the same DcimModuleBay mechanism as any other device.

Assign IP aggregates to a registry

Top-level address space can now be tracked back to the registry or private authority that assigned it, rather than only appearing as a role on a generic prefix.

What changed:

  • extensions/aggregate (new) adds an Aggregate node for top-level IPv4/IPv6 blocks, and an RIR node (with an is_private flag) that an aggregate points to.

Model racks and standalone sites independently of a full location hierarchy

A rack or a site can now be loaded on its own, without pulling in the rest of a country/metro/site hierarchy.

What changed:

  • extensions/rack (new) splits LocationRack out of extensions/location_minimal into its own extension, and relates it to a site through an explicit site/racks relationship rather than hierarchical nesting. It adds status, serial_number, and asset_tag attributes that the rack did not have before.
  • extensions/location_site (new) adds a standalone LocationSite node (facility, physical address, timezone, status) with no region or country tier above it. It defines the same Location.Site node as extensions/location_minimal, so load one or the other, not both.
  • extensions/location_minimal's hierarchy changes from Country → Metro → Site → Rack to Region → Country → Site (the Metro tier is removed, a Region tier is added above Country, and Rack moves to its own extension as described above).

Scope tenancy to devices, prefixes, and hosted locations

A tenant can now own the devices, address space, and locations it's responsible for directly, instead of only circuits and buildings.

What changed:

  • extensions/tenancy (promoted from experimental/tenancy, and rebuilt) wires a Tenant node to DcimGenericDevice (devices), IpamPrefix (prefixes), IpamIPAddress (addresses), and LocationHosting (locations). It no longer depends on extensions/circuit; extending tenancy onto circuits or other optional nodes is documented as a pattern you apply yourself in tenancy.yml.

Align VRFs, interfaces, and VLANs with industry standards

Several base and extension schemas are retyped or restructured to match industry standards, and to fix values that couldn't previously be expressed at all.

What changed:

  • IpamVRF.import_rt and export_rt change from cardinality: one to cardinality: many, so a VRF can import or export more than one route target — the corresponding IpamRouteTarget relationship splits into import_vrf/export_vrf.
  • DcimInterface's default mtu of 1514 (the full Ethernet frame size) is removed from the shared generic; InterfacePhysical now sets a default of 1500 (the IP payload size) directly. InterfaceVirtual no longer carries an mtu attribute.
  • DcimInterface.role drops the lag choice (LAG is already modeled by InterfaceLag) and renames cust to customer. DcimInterface.status drops deleted and outage and is now mandatory.
  • QinQ moves from a qinq_role Dropdown patched onto the generic IpamVLAN (extensions/qinq/qinq.yaml) to dedicated IpamSVLAN and IpamCVLAN node types (extensions/qinq/qinq.yml — note the corrected .yml extension) built on a new GenericVLAN base. A CVLAN's name is computed from its parent SVLAN and VLAN ID.
  • IpamL2Domain is replaced by IpamVLANGroup, scoped to a location the same way extensions/aggregate scopes to an RIR.
  • extensions/topology's schema is removed; experimental/topology remains the only topology model in the library.
  • SNMP community/client enums (SnmpCommunityV2.access, SnmpCommunityV3.auth_protocol/privacy_protocol) and BGPSession.session_type change from kind: Text with an enum: list to kind: Dropdown, and their stored values change case (e.g. EXTERNALexternal, Read-Onlyread_only).

Bug fixes

  • RoutingBGPSession.import_routing_policies/export_routing_policies pointed to the generic RoutingPolicy peer and reused the same relationship identifiers (bgp__import_policies/bgp__export_policies) as the equivalent relationships on RoutingBGPPeerGroup. Both now point to RoutingPolicyBGP and use distinct identifiers, so a session's routing policies no longer risk being conflated with a peer group's.
  • IpamIPAddress.interface and InterfaceLayer3.ip_addresses are now linked with a matching identifier, so the two sides resolve as one relationship instead of being treated as unrelated.

Minor changes

Documentation

  • Regenerated the schema reference docs (docs/docs/reference/*.mdx) for every base and extension schema to reflect the v2.0 model, and added dedicated reference pages for aggregate, device_module, device_psu_module, location_site, and rack.
  • Added a standard header to every base and extension schema file noting that it's a starting point, not a finished production model, and pointing to docs.infrahub.app or OpsMill for architectural review.

Developer Experience

  • Added a load_demo_ipam_dcim invoke task that loads the base schema plus a curated set of extensions (aggregate, cable, circuit, compute, cluster, hosting_cluster, lag, location_minimal, vlan, qinq, rack, vrf) for a demo environment.

Reliability

  • extensions/firewall_policer moves to experimental/firewall_policer.

Full changelog

Added

  • extensions/aggregateAggregate and RIR nodes for top-level IP address space. (#75)
  • extensions/device_moduleDcimModuleBay, Module, and ModuleType nodes, replacing extensions/modules. (#75)
  • extensions/device_psu_module — PSU-specific module type with wattage and hotswappable. (#75)
  • extensions/rack — standalone LocationRack, split out of extensions/location_minimal. (#75)
  • extensions/location_site — standalone LocationSite with no region/country hierarchy. (#75)
  • extensions/tenancy — promoted from experimental/tenancy, wired to devices, prefixes, addresses, and hosted locations. (#75)
  • DcimCircuit.commit_rate, DcimCircuitEndpoint.side, IpamVRF.enforce_unique, IpamIPAddress.status/role, SnmpCommunity.devices, SnmpClient.ip_address. (#75)
  • tasks/schemas.py: load_demo_ipam_dcim invoke task. (#75)

Changed

  • IpamVRF.import_rt/export_rtcardinality: one to many. (#75)
  • DcimInterface.mtu — default 1514 to 1500, moved from the shared generic to InterfacePhysical only. (#75)
  • DcimInterface.role/status choices reduced and renamed (custcustomer, lag removed; deleted/outage removed). (#75)
  • DcimDevice.status choices — drained removed, reserved/deprecated added. (#75)
  • extensions/qinq — rebuilt around IpamSVLAN/IpamCVLAN; file renamed qinq.yamlqinq.yml. (#75)
  • IpamL2DomainIpamVLANGroup, scoped via the new IpamVLANGroupScope mixin. (#75)
  • extensions/location_minimal hierarchy — Country → Metro → Site → Rack to Region → Country → Site; Site.facility_idfacility. (#75)
  • DcimCircuit.circuit_type choices — upstream replaced by internet_access, point_to_point added. (#75)
  • DcimCircuitEndpoint.name — now a computed attribute (circuit ID + side), replacing free text. (#75)
  • VRRPGroup.groupvrid (Text to Number); VRRPGroup.ip_addressip_addresses. (#75)
  • hosting_cluster's cluster_typetechnology; cloud-specific choices (aws, gcp) replaced by public_cloud. (#75)
  • BGPSession.session_type, SNMP community/client enums — Text+enum to Dropdown, values changed to lowercase/snake_case. (#75)
  • extensions/firewall_policer moved to experimental/firewall_policer. (#75)
  • extensions/topology schema removed (superseded by experimental/topology). (#75)
  • LocationGeneric/LocationHostingshortname removed; human_friendly_id switches to name. (#75)
  • IpamPrefixorganization, location, gateway relationships removed in favor of the new scope relationship (IpamPrefixScope); role choices fully replaced ({loopback, management, public, server, supernet, technical, loopback-vtep}{management, link, customer, supernet, backbone}). (#75)

Fixed

  • DcimCircuit.enpointsendpoints typo. (#75)
  • DcimInterface.role custcustomer typo/abbreviation. (#75)
  • RoutingBGPSession routing-policy relationships retyped to RoutingPolicyBGP with distinct identifiers, no longer colliding with RoutingBGPPeerGroup's. (#75)
  • IpamIPAddress.interface / InterfaceLayer3.ip_addresses given a matching identifier so both sides resolve as one relationship. (#75)

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 22, 2026

Copy link
Copy Markdown

Deploying schema-library with  Cloudflare Pages  Cloudflare Pages

Latest commit: dce6f94
Status: ✅  Deploy successful!
Preview URL: https://d9ce0492.schema-library.pages.dev
Branch Preview URL: https://bgi-schema-library-v2.schema-library.pages.dev

View logs

@BaptisteGi
BaptisteGi marked this pull request as ready for review June 23, 2026 15:33
@lancamat1
lancamat1 self-requested a review July 12, 2026 09:31
@BaptisteGi
BaptisteGi changed the base branch from release-2.0 to main August 6, 2026 13:29
@BaptisteGi BaptisteGi changed the title feat: draft for schema library v2 Schema library v2 Aug 6, 2026

@petercrocker petercrocker left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed this from the angle of importing the NetBox devicetype-library into it (that's what #76 is for), but items 3 and 5 stand independently of that work. Evidence below is from the branch as it stands plus testing against Infrahub 1.10.6.


1. Blocking — DcimModuleBay.position cannot hold NetBox bay positions

extensions/device_module/device_module.yml:

- name: position
  kind: Number
  parameters: {min_value: 1}
  optional: false
uniqueness_constraints:
  - ["device", "position__value"]

NetBox device-type definitions are the main external source of bay data, and their position is a free-form string that is not unique within a device. Checked against three real published device types, all three fail, each on a different constraint:

Device type Bays Fails on
Arista DCS-7508N 24 14 non-numeric positions — F1F6, PSU-1PSU-8
Cisco Catalyst 9300-48P 6 A and B non-numeric, and duplicate position 1 (Network Module + FAN 1) violating [device, position__value]
Juniper EX4300-48T 3 position 0 violates min_value: 1

Three separate constraints, three devices, 100% failure — so this isn't an edge case in the source data, it's the normal shape of it.

Proposed: make position kind: Text and drop the ["device", "position__value"] uniqueness constraint. If a numeric sort key is wanted, an additional optional Number attribute alongside the verbatim string would give that without rejecting real data.

2. Blocking — NetBox's bay label has nowhere to go

DcimModuleBay has name, position and description, but no equivalent of NetBox's bay label — the short role marker (Supervisor, Line Card). On a DCS-7508N, 10 of 24 bays carry one, and it's the only thing distinguishing supervisor slots from line-card slots.

Could we add an optional bay_label? One caution worth baking into the name: do not call it label. Infrahub auto-populates an attribute literally named label from name when it is unset, and title-cases it — verified on 1.10.6, where an unlabelled bay came back labelled with its own name, and a probe named LabelTest Bay became Labeltest Bay. That makes "no label supplied" indistinguishable from "label equals name".

3. Blocking — three extensions inherit generics this PR deletes

The PR body says:

extensions/modules_linecards and extensions/modules_routing_engine now depend on extensions/device_module instead of the removed extensions/modules.

Only .metadata.yml was updated. The schema files still inherit the old Device-namespaced generics, which nothing on this branch defines any more (device_module defines DcimGenericModule / DcimGenericModuleType):

experimental/modules_linecards/linecard.yml:19          - DeviceGenericModuleType
experimental/modules_linecards/linecard.yml:34          - DeviceGenericModule
experimental/modules_routing_engine/routing_engine.yml:16   - DeviceGenericModuleType
experimental/modules_routing_engine/routing_engine.yml:30   - DeviceGenericModule
experimental/optical_transport/optical_transport.yml:648    - DeviceGenericModuleType
experimental/optical_transport/optical_transport.yml:702    - DeviceGenericModule
experimental/optical_transport/optical_transport.yml:818    - DeviceGenericModule
experimental/optical_transport/optical_transport.yml:1122   - DeviceGenericModule

experimental/optical_transport also references them and isn't in the breaking-changes list at all — its header comment still points at extensions/modules/modules.yml. As shipped, all three should fail to load.

4. Question — can DcimModule be templated, and can a spare exist?

DcimGenericModule.module_bay is optional: false, and DcimModule.computed_name is {{ module_bay__computed_name__value }} ({{ module_type__name__value }}), so a module's identity derives from the bay it sits in.

Two consequences worth confirming were intended:

  1. Object templates. Importing NetBox module types as reusable blueprints means creating a TemplateDcimModule, which by definition isn't installed anywhere. Infrahub relaxes mandatory relationships and strips unique attributes on generated templates — I've verified both on 1.10.6 — but a computed identity dereferencing an absent relationship is a different case, and I haven't been able to test it. If it doesn't resolve, module types can't be templated at all under this model.
  2. Spares. The comment says spares awaiting installation aren't modelled. That's a legitimate scoping call, but it means a PSU on a shelf can't be tracked, which is a common inventory ask — worth stating in the extension README rather than only in a YAML comment.

5. Smaller items

DcimGenericModuleType has no weight attribute. NetBox module types publish one (a DCS-7500-SUP2 is 5 lb). Since Infrahub has no float kind and integer kilograms round a supervisor or transceiver to 0, an optional weight_grams Number would keep them distinct and sortable.

DcimDeviceType.weight remains an integer Number. Same root cause, pre-existing rather than introduced here — but a large share of the published library is sub-500 g (transceivers, access points) and rounds to 0, and a zero reads as data rather than as a missing value. A weight_grams companion would fix it; happy to raise separately if it's out of scope for v2.

The comment on DcimModuleBay.device contradicts the code:

- name: device
  peer: DcimPhysicalDevice
  # DcimGenericDevice (not DcimPhysicalDevice) so `name` is resolvable in computed_name above -
  # PhysicalDevice itself carries no identifying attribute.

The peer is DcimPhysicalDevice, and it works because this PR adds name to that generic — so the comment describes an approach that was abandoned. Worth updating before it misleads someone.

name is now declared unique: true on both DcimGenericDevice and DcimPhysicalDevice, and DcimDevice inherits both. DcimPhysicalDevice.name carries allow_override: any and the note "Here for computed attribute". Is the second unique declaration needed, or would anchoring the computed attribute at DcimGenericDevice avoid duplicating identity across two co-inherited generics?


Items 1 and 2 are what block #76 specifically: without them a modular chassis can't be imported at all, and bay labels have nowhere to land. Happy to open a PR against this branch for any of these if that's easier than patching them yourself.

@petercrocker petercrocker left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second pass, this time over the PR as a whole rather than the module extensions. I ran a cross-reference check over all 57 schema files (189 kinds defined, 569 references) and diffed the results against main, so everything below is new in this branch unless marked otherwise.

All static analysis — I have not been able to load v2 against a running Infrahub, so finding 4 in particular is inferred rather than observed.


1. This branch reverts a fix that is already on main

The branch is 2 commits behind main, and the single file it overlaps on is the one those commits fixed:

merge-base:        a7ccf78
main since then:   d5ae01b  fix(security): update stale Infra kind references to current Dcim/Ipam namespaces
                   fa16de3  Merge pull request #74
overlapping file:  experimental/security/security.yml

main now has DcimGenericDevice / DcimInterface / DcimEndpoint / IpamIPAddress / IpamPrefix in that file. This branch still has InfraGenericDevice / InfraInterface / InfraEndpoint / InfraIPAddress / InfraPrefix:

experimental/security/security.yml:137    peer: InfraIPAddress
experimental/security/security.yml:158    peer: InfraPrefix
experimental/security/security.yml:486    - InfraGenericDevice
experimental/security/security.yml:493    menu_placement: "InfraGenericDevice"
experimental/security/security.yml:616    - InfraInterface
experimental/security/security.yml:617    - InfraEndpoint

Merging as-is either silently undoes #74 or produces a conflict that is easy to resolve the wrong way. A rebase on main fixes it.

2. experimental/tenancy was promoted but not deleted

The body says extensions/tenancy is "promoted from experimental/tenancy, and rebuilt", and the .metadata.yml entry for experimental/tenancy is indeed gone — but the directory is still present (2 files) and still defines TenancyTenant. So the kind is declared twice, and the old copy is orphaned: no metadata entry, invisible to the dependency graph, still loadable by anyone pointing at the path.

Assuming the promotion was intended to replace it, experimental/tenancy/ should be removed.

3. extensions/meet_me_room is removed with no mention anywhere

Extensions removed on this branch: extensions/modules, extensions/topology, extensions/meet_me_room, plus extensions/firewall_policer moved to experimental/. The first two and the move are covered in the body; meet_me_room gets zero mentions, including in the breaking-changes list. Either it should be listed as a removal or it was deleted by accident.

4. IpamVLANGroupScope is declared twice, and base is always loaded

# base/ipam.yml:14 — bare stub, no description, no relationships
- name: VLANGroupScope
  namespace: Ipam
  include_in_menu: false

# extensions/vlan/vlan.yml:11 — the real definition
- name: VLANGroupScope
  namespace: Ipam
  description: Mixin for objects that can scope a VLAN group (e.g. Region, Site, Rack).
  relationships:
    - name: vlan_groups
      peer: IpamVLANGroup
      identifier: vlan_group__scope

The stub looks deliberate — it lets extensions/location_minimal and extensions/location_site inherit IpamVLANGroupScope without taking a dependency on extensions/vlan. The problem is that base is always loaded, so base + extensions/vlan puts two declarations of one kind into a single load. Per opsmill/infrahub#10139, two declarations of the same kind merge by payload order and one is silently discarded; if the stub wins, vlan_groups disappears with no error.

Worth either guaranteeing the load order explicitly, or forward-declaring in a way that cannot lose the relationship.

5. LocationSite (and LocationRegion) declared in two extensions with no mutual-exclusion signal

  • LocationSiteextensions/location_site and extensions/location_minimal
  • LocationRegionexperimental/location_extended and extensions/location_minimal

The location_extended vs location_minimal pair reads as obviously choose-one, and LocationCountry / LocationRack already overlap that way on main. But location_site and location_minimal are both under extensions/, so nothing signals they can't be loaded together. A note in .metadata.yml or in the two READMEs would prevent it.

6. Pre-existing dangling references, not introduced here

Flagging only because v2 is billed as a full audit pass, so these may be in scope:

Unresolved kind Referenced from
CircuitEndpoint experimental/circuit_service
InfraIPAddress, InfraPrefix, InfraVLAN experimental/topology

Note that extensions/topology is removed on this branch, so the surviving topology extension is the broken experimental one.


Clean: yamllint passes across base/, extensions/, experimental/ and .metadata.yml; find_cycles.py reports no cycles in either the dependency or reverse-dependency graph; every .metadata.yml entry maps to a real directory; and there are no duplicate kinds or unresolved references beyond those listed above.

@BaptisteGi

Copy link
Copy Markdown
Contributor Author

Second pass, this time over the PR as a whole rather than the module extensions. I ran a cross-reference check over all 57 schema files (189 kinds defined, 569 references) and diffed the results against main, so everything below is new in this branch unless marked otherwise.

All static analysis — I have not been able to load v2 against a running Infrahub, so finding 4 in particular is inferred rather than observed.

1. This branch reverts a fix that is already on main

The branch is 2 commits behind main, and the single file it overlaps on is the one those commits fixed:

merge-base:        a7ccf78
main since then:   d5ae01b  fix(security): update stale Infra kind references to current Dcim/Ipam namespaces
                   fa16de3  Merge pull request #74
overlapping file:  experimental/security/security.yml

main now has DcimGenericDevice / DcimInterface / DcimEndpoint / IpamIPAddress / IpamPrefix in that file. This branch still has InfraGenericDevice / InfraInterface / InfraEndpoint / InfraIPAddress / InfraPrefix:

experimental/security/security.yml:137    peer: InfraIPAddress
experimental/security/security.yml:158    peer: InfraPrefix
experimental/security/security.yml:486    - InfraGenericDevice
experimental/security/security.yml:493    menu_placement: "InfraGenericDevice"
experimental/security/security.yml:616    - InfraInterface
experimental/security/security.yml:617    - InfraEndpoint

Merging as-is either silently undoes #74 or produces a conflict that is easy to resolve the wrong way. A rebase on main fixes it.

2. experimental/tenancy was promoted but not deleted

The body says extensions/tenancy is "promoted from experimental/tenancy, and rebuilt", and the .metadata.yml entry for experimental/tenancy is indeed gone — but the directory is still present (2 files) and still defines TenancyTenant. So the kind is declared twice, and the old copy is orphaned: no metadata entry, invisible to the dependency graph, still loadable by anyone pointing at the path.

Assuming the promotion was intended to replace it, experimental/tenancy/ should be removed.

3. extensions/meet_me_room is removed with no mention anywhere

Extensions removed on this branch: extensions/modules, extensions/topology, extensions/meet_me_room, plus extensions/firewall_policer moved to experimental/. The first two and the move are covered in the body; meet_me_room gets zero mentions, including in the breaking-changes list. Either it should be listed as a removal or it was deleted by accident.

4. IpamVLANGroupScope is declared twice, and base is always loaded

# base/ipam.yml:14 — bare stub, no description, no relationships
- name: VLANGroupScope
  namespace: Ipam
  include_in_menu: false

# extensions/vlan/vlan.yml:11 — the real definition
- name: VLANGroupScope
  namespace: Ipam
  description: Mixin for objects that can scope a VLAN group (e.g. Region, Site, Rack).
  relationships:
    - name: vlan_groups
      peer: IpamVLANGroup
      identifier: vlan_group__scope

The stub looks deliberate — it lets extensions/location_minimal and extensions/location_site inherit IpamVLANGroupScope without taking a dependency on extensions/vlan. The problem is that base is always loaded, so base + extensions/vlan puts two declarations of one kind into a single load. Per opsmill/infrahub#10139, two declarations of the same kind merge by payload order and one is silently discarded; if the stub wins, vlan_groups disappears with no error.

Worth either guaranteeing the load order explicitly, or forward-declaring in a way that cannot lose the relationship.

5. LocationSite (and LocationRegion) declared in two extensions with no mutual-exclusion signal

  • LocationSiteextensions/location_site and extensions/location_minimal
  • LocationRegionexperimental/location_extended and extensions/location_minimal

The location_extended vs location_minimal pair reads as obviously choose-one, and LocationCountry / LocationRack already overlap that way on main. But location_site and location_minimal are both under extensions/, so nothing signals they can't be loaded together. A note in .metadata.yml or in the two READMEs would prevent it.

6. Pre-existing dangling references, not introduced here

Flagging only because v2 is billed as a full audit pass, so these may be in scope:

Unresolved kind Referenced from
CircuitEndpoint experimental/circuit_service
InfraIPAddress, InfraPrefix, InfraVLAN experimental/topology
Note that extensions/topology is removed on this branch, so the surviving topology extension is the broken experimental one.

Clean: yamllint passes across base/, extensions/, experimental/ and .metadata.yml; find_cycles.py reports no cycles in either the dependency or reverse-dependency graph; every .metadata.yml entry maps to a real directory; and there are no duplicate kinds or unresolved references beyond those listed above.

1/ Apparently there is no conflict
2/ Done
3/ It was empty
4/ So far it doesn't create issue, I'll keep an eye on this one
5/ Eventually people will load only one or the other

@BaptisteGi

Copy link
Copy Markdown
Contributor Author

Reviewed this from the angle of importing the NetBox devicetype-library into it (that's what #76 is for), but items 3 and 5 stand independently of that work. Evidence below is from the branch as it stands plus testing against Infrahub 1.10.6.

1. Blocking — DcimModuleBay.position cannot hold NetBox bay positions

extensions/device_module/device_module.yml:

- name: position
  kind: Number
  parameters: {min_value: 1}
  optional: false
uniqueness_constraints:
  - ["device", "position__value"]

NetBox device-type definitions are the main external source of bay data, and their position is a free-form string that is not unique within a device. Checked against three real published device types, all three fail, each on a different constraint:

Device type Bays Fails on
Arista DCS-7508N 24 14 non-numeric positions — F1F6, PSU-1PSU-8
Cisco Catalyst 9300-48P 6 A and B non-numeric, and duplicate position 1 (Network Module + FAN 1) violating [device, position__value]
Juniper EX4300-48T 3 position 0 violates min_value: 1
Three separate constraints, three devices, 100% failure — so this isn't an edge case in the source data, it's the normal shape of it.

Proposed: make position kind: Text and drop the ["device", "position__value"] uniqueness constraint. If a numeric sort key is wanted, an additional optional Number attribute alongside the verbatim string would give that without rejecting real data.

2. Blocking — NetBox's bay label has nowhere to go

DcimModuleBay has name, position and description, but no equivalent of NetBox's bay label — the short role marker (Supervisor, Line Card). On a DCS-7508N, 10 of 24 bays carry one, and it's the only thing distinguishing supervisor slots from line-card slots.

Could we add an optional bay_label? One caution worth baking into the name: do not call it label. Infrahub auto-populates an attribute literally named label from name when it is unset, and title-cases it — verified on 1.10.6, where an unlabelled bay came back labelled with its own name, and a probe named LabelTest Bay became Labeltest Bay. That makes "no label supplied" indistinguishable from "label equals name".

3. Blocking — three extensions inherit generics this PR deletes

The PR body says:

extensions/modules_linecards and extensions/modules_routing_engine now depend on extensions/device_module instead of the removed extensions/modules.

Only .metadata.yml was updated. The schema files still inherit the old Device-namespaced generics, which nothing on this branch defines any more (device_module defines DcimGenericModule / DcimGenericModuleType):

experimental/modules_linecards/linecard.yml:19          - DeviceGenericModuleType
experimental/modules_linecards/linecard.yml:34          - DeviceGenericModule
experimental/modules_routing_engine/routing_engine.yml:16   - DeviceGenericModuleType
experimental/modules_routing_engine/routing_engine.yml:30   - DeviceGenericModule
experimental/optical_transport/optical_transport.yml:648    - DeviceGenericModuleType
experimental/optical_transport/optical_transport.yml:702    - DeviceGenericModule
experimental/optical_transport/optical_transport.yml:818    - DeviceGenericModule
experimental/optical_transport/optical_transport.yml:1122   - DeviceGenericModule

experimental/optical_transport also references them and isn't in the breaking-changes list at all — its header comment still points at extensions/modules/modules.yml. As shipped, all three should fail to load.

4. Question — can DcimModule be templated, and can a spare exist?

DcimGenericModule.module_bay is optional: false, and DcimModule.computed_name is {{ module_bay__computed_name__value }} ({{ module_type__name__value }}), so a module's identity derives from the bay it sits in.

Two consequences worth confirming were intended:

  1. Object templates. Importing NetBox module types as reusable blueprints means creating a TemplateDcimModule, which by definition isn't installed anywhere. Infrahub relaxes mandatory relationships and strips unique attributes on generated templates — I've verified both on 1.10.6 — but a computed identity dereferencing an absent relationship is a different case, and I haven't been able to test it. If it doesn't resolve, module types can't be templated at all under this model.
  2. Spares. The comment says spares awaiting installation aren't modelled. That's a legitimate scoping call, but it means a PSU on a shelf can't be tracked, which is a common inventory ask — worth stating in the extension README rather than only in a YAML comment.

5. Smaller items

DcimGenericModuleType has no weight attribute. NetBox module types publish one (a DCS-7500-SUP2 is 5 lb). Since Infrahub has no float kind and integer kilograms round a supervisor or transceiver to 0, an optional weight_grams Number would keep them distinct and sortable.

DcimDeviceType.weight remains an integer Number. Same root cause, pre-existing rather than introduced here — but a large share of the published library is sub-500 g (transceivers, access points) and rounds to 0, and a zero reads as data rather than as a missing value. A weight_grams companion would fix it; happy to raise separately if it's out of scope for v2.

The comment on DcimModuleBay.device contradicts the code:

- name: device
  peer: DcimPhysicalDevice
  # DcimGenericDevice (not DcimPhysicalDevice) so `name` is resolvable in computed_name above -
  # PhysicalDevice itself carries no identifying attribute.

The peer is DcimPhysicalDevice, and it works because this PR adds name to that generic — so the comment describes an approach that was abandoned. Worth updating before it misleads someone.

name is now declared unique: true on both DcimGenericDevice and DcimPhysicalDevice, and DcimDevice inherits both. DcimPhysicalDevice.name carries allow_override: any and the note "Here for computed attribute". Is the second unique declaration needed, or would anchoring the computed attribute at DcimGenericDevice avoid duplicating identity across two co-inherited generics?

Items 1 and 2 are what block #76 specifically: without them a modular chassis can't be imported at all, and bay labels have nowhere to land. Happy to open a PR against this branch for any of these if that's easier than patching them yourself.

1/ Done
2/ Created role attribute for that
3/ Yeah these schema will need to make it to the extension one day
4/ Right now templating module isn't bringing a lot as everything is stored in the type, if one day we track interfaces under a given module it would make sense. Created an issue for the spare use case as it requires more work.
5/ I don't think we support float attribute yet, I think it will be nice to add them once we get this new kind

@lancamat1

Copy link
Copy Markdown

We've been building a customer schema on this branch, pinned at a01fc07, against Infrahub 1.11.0 — base/{organization,location,dcim,ipam} plus extensions/{location_minimal,tenancy,vlan,qinq,vrf,lag}. It loads clean: 11 files in one infrahubctl schema load, zero errors, with our own nodes inheriting from and extending library kinds. Two pieces of feedback from real use.

The Q-in-Q rebuild is why we're on v2 at all

Dedicated IpamSVLAN / IpamCVLAN types with a real svlancvlan relationship map directly onto a carrier Metro Ethernet network doing straight Q-in-Q. On v1 we would have hand-authored the single most important primitive in this customer's network. That alone justified taking an unreleased branch.

IpamVRF.import_rt and export_rt going from cardinality: one to many is the other one — realistic VPLS route-target sets aren't expressible otherwise. Both landed exactly where we needed them.

location_minimal's hierarchy is rigid in a way that blocks single-country deployments

The three levels are hard-wired: LocationSite.parent → LocationCountry required, LocationCountry.parent → LocationRegion required with cardinality: one, and every level's name is unique: true.

For an operator inside one country that makes LocationRegion unusable for any sub-national grouping. Our customer organises everything around markets — service delivery, layer-3 isolation, their migration plan — so the natural modelling was market at Region. It cannot work: "United States" can exist exactly once and hang beneath exactly one Region, so N markets cannot each own a copy of it. It isn't awkward, it doesn't load.

We settled on market at LocationCountry, with LocationRegion as a single national node, relabelled in the UI menu. That works, but the kind name reads wrong to anyone inspecting the schema, and LocationRegion becomes a one-node level carrying no meaning.

Worth considering whether the intermediate levels should be optional, or whether the extension should carry a note pointing single-country deployments at the Country level. Not blocking — we shipped around it — but it cost a redesign after loading data disproved the first attempt, and the constraint is invisible until you try.

One small docs thing

The mutual exclusivity between location_minimal and location_site is stated in location_site's description — "It defines the same Site node as extensions/location_minimal, so load one or the other, not both" — but not in location_minimal's. Coming from the hierarchy side, we picked location_site first, verified a clean load, then discovered we needed the hierarchy and had to swap and re-verify. A reciprocal clause in .metadata.yml under extensions/location_minimal would have saved that round trip, and would be a natural place to mention the parent/uniqueness constraint above.

@lancamat1

Copy link
Copy Markdown

Review pass done. I went through the net diff decision by decision, each judged against a checklist agreed up front, and opened the outcome as a draft PR against this branch: #80. One commit per decision, reasoning in the commit message, 55 commits total.

The counts: I agreed with 81 decisions as they are, 58 became the change commits in #80 (plus one revert, mtu back on the interface generic), and 6 open questions went to Baptiste in the Slack thread. The full verdict table with the reasoning for every accept is available if anyone wants it.

Everything on the #80 branch loads into a fresh Infrahub 1.11.0 with zero errors, schemas and all object files included.

* fix: keep DcimInterface.mtu on the generic so LAG and virtual interfaces keep an MTU

Packet: DCIM-06 (reject, redesign), keeps DCIM-07.

Moving mtu to InterfacePhysical left InterfaceLag and InterfaceVirtual with no MTU at all,
which is where a non-default MTU is most often needed (bundles, SVIs, tunnels, loopbacks).
The fix for a bad default (1514 was frame size) is the default, so mtu returns to the
DcimInterface generic with default_value 1500.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: pluralize cardinality-many relationship names on OrganizationManufacturer

Packet: ORG-N1 (change).

CC-2 wants plural names on cardinality-many relationships, and both declarations are
PR-touched. The rename also removes the clash with DcimGenericDevice.device_type and
.platform, which are cardinality one and mean something else. Identifiers stay singular
on the object part, so the forward sides in base/dcim.yml are untouched.

Checklist: CC-2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: declare LocationHosting.name identity explicitly

Packet: LOC-02 (change).

The human_friendly_id was silently deriving the uniqueness constraint, which CC-4 forbids;
unique: true states what Infrahub already enforces, so there is no behaviour change and no
migration. optional and order_weight are written on the same three lines, and display_label
plus order_by give the generic parity with LocationGeneric (it is a relationship peer in
seven places and had a null display label).

Checklist: CC-4, CC-6, CC-9

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ipam): align IpamPrefix.status choice order with the base status order

Packet: IPAM-05 (change, substance accepted).

The mandatory plus default_value plus base palette work is exactly what CC-5 and CC-6 ask
for; the one defect is that this node listed reserved after deprecated while DcimDevice,
GenericVLAN, LocationRack and IpamIPAddress all put reserved first (4:1). Two nodes in the
same file ordered the same dropdown differently.

Checklist: CC-5

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ipam): give IpamPrefix.role backbone its own color

Packet: IPAM-04 (color half only, the rest is a question to the author).

backbone and management both used #AEC6CF, so the two roles were indistinguishable in the UI
and the color carried no information. #9678B6 is freed by dropping the v1 technical role.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: declare explicit uniqueness constraints behind HFIDs (CC-4)

Packets: AGG-01, VLAN-01 (both change).

CC-4 forbids relying on the constraint Infrahub derives from the human_friendly_id.
IpamAggregate resolved to a derived [[prefix__value]] (namespace-less is correct here) and
IpamVLANGroup.name to a derived [[name__value]]; both are now stated in the file.

Checklist: CC-4

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: align attribute names with CC-2 naming rules

Packets: AGG-02, PSU-03, DCIM-CC2 (all change; DCIM-CC2 confirmed by Matej as a rename).

CC-2 says Booleans are bare snake_case with no is_ prefix (is_private -> private, 7:1 in the
tree) and no run-together words (hotswappable -> hot_swappable, named by the checklist).
DCIM-CC2 follows the CC-2 majority count, serial_number 3 against serial 2, so base
DcimPhysicalDevice and DcimGenericSFP move to serial_number. The matching object files are
renamed in the same commit so the data still loads.

Checklist: CC-2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: order uniqueness_constraints to match the namespace-first HFID

Packets: IPAM-07 (change), base_ipam consistency note 3, ruling R5 (IpamCVLAN part deferred
to the QinQ scoping change).

The namespace-first human_friendly_id flip is accepted, but the declared constraints still
read value-first, so Infrahub no longer recognises them as the same constraint and adds its
derived one on top: the live schema carried two functionally identical constraints on
IpamPrefix, IpamIPAddress and DcimCrossConnect. Reordering restores one explicit constraint
and no implicit one.

Checklist: CC-4

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cable): give DcimCable a human_friendly_id backed by an explicit unique label

Packet: CABLE-02 (change).

order_by and display_label both point at label, which was optional and non-unique, so
unlabelled cables rendered an empty display label and sorted arbitrarily. Making label the
node's declared identity fixes both and covers CC-4's HFID-less-node list.

Checklist: CC-4

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(cluster): rename ClusterGeneric.location to locations

Packet: CLU-01 (change).

ClusterGeneric.location is cardinality many with a singular name, and CC-2 makes plural the
rule. No identifier is declared on either side and the reverse LocationGeneric.clusters is
named independently, so the rename does not have to be mirrored.

Checklist: CC-2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(compute,hosting_cluster): group compute units under a visible ComputeGenericUnit

Packets: COMP-03, HOST-03 (both escalated, decision Q6-B).

CC-3 says menu placement follows the node's own domain: a Compute server filed under
DcimDevice ("Network Device") and a Cluster filed under VirtualizationVirtualMachine both
broke that, the latter also inverting containment. ComputeGenericUnit becomes the visible
anchor, which is the library's own idiom and yields a real "all compute units" list.
CLU-01 stands as the author wrote it: ClusterGeneric stays hidden.

Checklist: CC-3, CC-9

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cross_connect): align status with the base mandatory+default+palette convention

Packet: XCON-CC6 (change, no packet owns the file).

CC-6 names cross_connect as one of the status deviants: status is mandatory with a
default_value everywhere else. planned is the safe default, since a cross-connect has to be
ordered from a carrier before it is connected. The three colors were still the v1 palette.

Checklist: CC-5, CC-6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(device_module): make DcimModuleBay.position a validated Number

Packet: MOD-04 (change).

Text is the wrong kind: the description documented NetBox's {module} token substitution,
which nothing here implements, the sibling DcimPhysicalDevice.position in the same
namespace is a Number, and the author's own first version (98a730f) was Number with
min_value 1. No uniqueness constraint restored: position is optional.

Checklist: CC-2

Note: objects/extensions/device_module/device_module.yml still quotes the three values;
unquoting them is the paired objects-file fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(device_module): give DcimGenericModule.computed_name a default Jinja2 template

Packet: MOD-06 (change).

The generic declared computed_name as read_only + mandatory with no computed_attribute, so
it could be neither set by a client nor computed by the server, and any node inheriting the
generic without overriding it could not be created. The generic can compute from module_bay,
which it owns; concrete kinds keep their own override. The stale "Overriten at node level"
comment goes with it.

Checklist: CC-4, CC-8

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(device_psu_module): label and validate DcimPSUModuleType.wattage

Packet: PSU-02 (change).

CC-2 requires units in parentheses in the label, which base does uniformly ("Height (U)",
"Weight (kg)"), and wattage carried no label at all. Commit 8eae840 in this PR set the
min_value precedent; without it a wattage accepts 0 and negatives.

Checklist: CC-2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dwdm,patch_panel): restore the three connector types dropped by a stale 4096-char FIXME

Packets: CONN-01 (change), CONN-02 (ruling R6).

The FIXME's premise is false on 1.11.0: the full 28-choice list loads at roughly 3.3 KB
against a 4096-char limit. CONN-02 keeps the two lists duplicated, so each now carries a
sync comment pointing at the other; a shared generic in a common ancestor is the real
deduplication and is a base-tier change for a separate release.

The three restored choice names keep the surrounding UPPERCASE form; the case sweep owns
all 28 values at once.

Checklist: CC-5, CC-8

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dwdm): keep connector_type mandatory, matching every other hardware-type enum in the domain

Packet: DWDM-03 (change).

The flip to optional was unstated and left one identical 25-choice list with two
optionalities inside one domain. DcimGenericSFP.sfp_type, form_factor and
DcimGenericPatchPanelInterface.connector_type are all mandatory; CC-6's "no sensible
default" exception covers a mandatory connector type, and CONN-01 removed the one argument
for optional by restoring the missing choices.

Checklist: CC-6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(location,rack): align the retired lifecycle choice to the base deprecated vocabulary

Packets: LMIN-03, RACK-02 (both change).

CC-2 resolves split vocabulary by majority. Counted across base/ and extensions/, the choice
name deprecated wins 5:2 and the label Deprecated 4:3, so both Sites rename. The Rack already
used name: deprecated with label: Retired, exactly the name/label split CC-2 exists to close.

Checklist: CC-2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(patch_panel): namespace the front/rear interface relationship identifiers

Packet: PP-01 (identifier half; the dependency and description halves belong to the metadata
commit).

CC-7 wants identifiers in subject__object form. DWDM-01 fixed the dwdm half of the v1
collision and left these two bare; v2.0 is the last release that may rename a stored
relationship identifier.

Checklist: CC-7

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(rack): scope rack identity to its site and pair site/racks as Parent/Component

Packet: RACK-01 (change).

Two defects rode in on the split. CC-4: name was globally unique, so two sites could not each
have a rack called R01, and rack names repeat across sites in every real estate; identity is
now scoped by site with the uniqueness declared explicitly. CC-7: LocationRack.site was
Attribute while its reverse LocationSite.racks is Component, and Parent is what pairs with
Component. Deletion behaviour is unchanged, the site side stays Component and site stays
mandatory.

Checklist: CC-4, CC-7

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(rack): rename facility_id to facility to match LocationSite

Packet: RACK-04 (change).

CC-2's explicit call: facility beats facility_id 2:1, and the rack either aligns or documents
that it is a different concept. The attribute carries no description at all, so nothing in the
file distinguishes a rack's local identifier from a site's. The objects file moves with it or
the load breaks.

Checklist: CC-2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(snmp): peer SnmpCommunity.devices at DcimGenericDevice

Packet: SNMP-03 (change).

Adding the link is right, the peer was not: concrete DcimDevice silently excludes
ComputePhysicalServer, VirtualizationVirtualMachine and DcimPatchPanel, and servers and VMs
are the most SNMP-polled things in an estate. TEN-02 in this same diff peers the base generic,
and DcimGenericDevice is base, so the change costs no dependency edge. CC-4's explicit
uniqueness behind the HFID name folds in here.

Checklist: CC-1, CC-4, CC-7

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(snmp): pluralize SnmpClient relationships and make name unique

Packet: SNMP-04 (change).

CC-2 makes plural the rule on cardinality-many relationships and routes untouched singulars
to their own packet; SnmpClient had two, the new ip_address and the pre-existing community.
CC-4 lists SnmpClient among the nodes whose HFID name is not explicitly unique. community
also had no kind, declared here as Attribute rather than left to the kind sweep.

Checklist: CC-2, CC-4

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(tenancy): rename TenancyTenant to OrganizationTenant and place it under Organization

Packets: TEN-01, TEN-04 (both change).

CC-3 names this node explicitly: a single-node Tenancy namespace whose only node inherits
OrganizationGeneric violates one-namespace-per-domain, and the folder keeps the feature name.
The missing label and menu_placement ride along, matching the two namespace siblings
OrganizationManufacturer and OrganizationProvider. TEN-04: tenant__ipaddress runs two words
together where every sibling identifier in the file separates them.

Checklist: CC-3, CC-7

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(vlan): fill IpamVLANGroupScope through the extensions mechanism

Packet: IPAM-03 (change).

The bare stub in base is forced: base loads standalone and first, so it cannot declare
vlan_groups without dangling on IpamVLANGroup. What was wrong is the mechanism the extension
used to fill it: re-declaring the whole generic under generics: replaces base's definition,
duplicates a kind across two files, and hides who fills the stub from a reader of base.
extensions: nodes: - kind: <GenericKind> is the documented and repo-established mechanism,
already used on LocationHosting by three extensions in this tree. The stub also gains the
label and description CC-2/CC-8 require.

Checklist: CC-1, CC-2, CC-8

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: rename GenericVLAN to IpamGenericVLAN and scope VLAN-ID uniqueness per kind

Packets: VLAN-03, QINQ-02 (both change; decision Q4-A).

CC-2 rejects a literal Generic namespace: the namespace is prepended to form the kind and
"Generic" is not a domain, so the convention is <Ns>Generic<Thing>, exactly the
DcimGenericDevice shape. Separately, [vlan_id__value, vlan_group] on the generic made
IpamVLAN, IpamSVLAN and IpamCVLAN share one flat VLAN-ID space per group, so two C-VLANs with
the same C-tag under different S-VLANs were rejected at create time. That is the central
Q-in-Q case. The constraint moves down to IpamVLAN and IpamSVLAN; IpamCVLAN keeps only its own
[vlan_id__value, svlan].

Checklist: CC-2, CC-4

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(qinq): order the IpamCVLAN uniqueness constraint to match its HFID

Packets: IPAM-07 cross-reference, base_ipam consistency note 3, ruling R5.

Third and last of the duplicate-constraint fixes; IpamPrefix, IpamIPAddress and
DcimCrossConnect landed earlier. IpamCVLAN's HFID reads [svlan__name__value, vlan_id__value]
while the declared constraint read [vlan_id__value, svlan], so Infrahub no longer recognised
them as the same constraint and added its derived one on top. Sequenced after the QinQ
scoping change so the reorder applies to the final constraint set.

Checklist: CC-4

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: stop a VRRP virtual IP deletion from cascading into its group

Packet: VRRP-02 (change, admitted under ruling R3).

Measured: deleting a single virtual IP address deleted the VRRP group and the group's other
virtual IP. The cause is kind: Component on IpamIPAddress.vrrp, which implies
on_delete: cascade and declares that an IP address owns a VRRP group. Every other Component
relationship in the library points container to children. The group to IPs cascade stays: a
VIP exists only for its group. A kind change does not touch the derived identifier, so no
data migration.

Checklist: CC-7

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(peering_ixp): do not cascade-delete BGP sessions with their IXP connection

Packet: IXP-02 (change).

Live-verified: PeeringIXPConnection.bgp_sessions resolves to Component, which defaults to
on_delete: cascade, while RoutingBGPSession.ixp_connection is optional. So deleting an IXP
connection silently deleted its BGP sessions with their policies and AS links, even though the
schema says a session need not have a connection. This is the only Component pair in the repo
whose child side is optional by necessity. kind stays Component for the separate-tab UI;
on_delete, not the kind, drives deletion.

Checklist: CC-7

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(snmp): follow the community and device renames in the objects file

Packets: SNMP-03, SNMP-04 (objects half).

The SNMP-04 verdict states the objects file populates neither renamed relationship; it does
populate community on SnmpClient three times, so the load would break on communities. The
header comment also still named DcimDevice and ip_address. Split out rather than folded
because both schema commits had already landed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(topology): point experimental topology at Ipam kinds

Packets: EXP-02 (STRUCT-01)

The three IPAM peers name an Infra* namespace that does not exist anywhere in
base or extensions, so the schema cannot load. This PR deletes
extensions/topology and makes this file the library's only topology model, so
the pre-existing breakage is now v2.0's to carry.

The missing extensions/vlan dependency edge lands with the merged .metadata.yml
dependency commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(circuit_service): use DcimCircuitEndpoint kind

Packets: EXP-03

CircuitEndpoint is not a kind anywhere in the library; the declared dependency
extensions/circuit defines DcimCircuitEndpoint. Two tokens stand between this
extension and loading, and the declared dependency list is already correct.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(location_extended): use name in LocationRack human_friendly_id

Packets: EXP-04
Checklist: CC-4

base/location.yml drops shortname from LocationGeneric in this PR and switches
the human_friendly_id to name, but LocationRack here still resolves its HFID
through shortname__value and defines no shortname of its own, so the schema no
longer loads. The break arrives entirely from base, so this PR owns it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(optical_transport): compute module names from the module bay in one hop

Packets: EXP-05 (STRUCT-05)

Re-pointing these three nodes from the removed DeviceGenericModule to
DcimGenericModule left the templates traversing module_bay -> device, and
Jinja2 computed attributes only resolve direct relationships, so the schema is
rejected. Switch to the one-hop idiom the rest of the library uses,
module_bay__computed_name__value, and correct the three description examples:
the name now keys off device name plus bay, not the device serial number.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(physical_disk): scope disk identity to its device

Packets: lead reviewer ruling, 2026-08-31 (new)
Checklist: CC-4

Disk names repeat across devices, so a bare name__value human_friendly_id
cannot address a disk; the node already declares the matching
[device, name__value] uniqueness constraint. Resolving device__name__value
needs a name on the DcimDeviceWithPhysicalDisks generic, so add one with
allow_override: any, following the precedent base/dcim.yml sets on
DcimPhysicalDevice.name for DcimModuleBay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(metadata): declare exactly the dependencies each extension uses

Packets: RTG-CC1, BGPC-CC1, IXP-03, STRUCT-11, STRUCT-12, CIRC-05, PP-01,
RACK-05, EXP-02
Checklist: CC-1

Every declared edge is now referenced by the extension's YAML and every kind an
extension references is owned inside its transitive closure. Twelve entries
change:

- extensions/circuit: drop extensions/location_minimal (DcimCircuit.location is
  gone, only LocationHosting from base remains) (CIRC-05)
- extensions/patch_panel: add extensions/device_module, and stop promising the
  removed DcimPatchPanelModule in the description (PP-01)
- extensions/rack: re-point extensions/location_minimal to
  extensions/location_site, the smallest provider of Location.Site, with the
  either-package note in the description since a flat list cannot say "one of"
  (RACK-05, ruling 1)
- extensions/routing: drop extensions/vlan, add extensions/vrf (RTG-CC1)
- extensions/routing_bgp_community: drop extensions/routing, add
  extensions/routing_policies (BGPC-CC1)
- extensions/routing_bgp_rr: drop extensions/routing (RTG-CC1)
- extensions/routing_policies_aggregate: drop extensions/routing and
  extensions/routing_aggregate (RTG-CC1)
- extensions/routing_policies_bgp: drop extensions/routing (RTG-CC1)
- extensions/routing_policies_ospf: drop extensions/routing (RTG-CC1)
- extensions/routing_policies_pim: drop extensions/routing (RTG-CC1)
- extensions/peering_ixp: drop extensions/routing and
  extensions/routing_policies, both unused and both reached transitively
  (IXP-03, STRUCT-11)
- experimental/topology: add extensions/vlan, now that the VLAN peer resolves to
  IpamVLAN (EXP-02)

routing_policies_aggregate.yml gains one comment line so the commented-out
extensions block records that uncommenting it needs the dropped
extensions/routing_aggregate edge back (RPOL-CC9 bucket b rider).

Resolver check after the edit: 57 nodes ordered, no cycle, no dangling edge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(metadata): declare mutual exclusivity between overlapping extensions and honour it in the loader

Packets: STRUCT-09, FR-004b, LSITE-01, EXP-06 (STRUCT-08)
Checklist: CC-1, CC-8

Two pairs of packages define the same kind and cannot be loaded together:
extensions/location_minimal and extensions/location_site both define
Location.Site, and experimental/location_extended and extensions/rack both
define Location.Rack. Until now co-loading was silent: the hierarchical Site
simply won in either order, so a user who picked location_site for a flat model
got the hierarchy with no warning, and the rack pair fails outright with
"Hierarchy must be provided if either parent or children is defined".

- .metadata.yml gains a machine-readable exclusive_with key on all four
  entries, declared in both directions, and reciprocal prose in every
  description, which CC-1 requires on both sides.
- location_minimal's description also records the constraint that stays
  invisible until data is loaded: LocationGeneric.name is unique across every
  location kind, so a single-country deployment enters the hierarchy at
  Country rather than duplicating a country under several regions (FR-004b
  field test; generic-level uniqueness itself stays as it is).
- Both location YAML files, plus rack and location_extended, carry the same
  note above their nodes block, and the three READMEs say it too, including
  the "LocationSite comes from either location package" line rack was missing.
- load_all_schemas honours the key through _select_extensions: a member is
  skipped once its partner is selected, so location_minimal, the superset, is
  what CI loads. Because the skip only fires after the partner is loaded, the
  partner always comes first and a dependency on the skipped member is
  satisfied by it, which is what makes rack's re-point at location_site safe
  for hierarchy users. An experimental package always yields to the extension
  it collides with, whatever the load order says, so rack survives a
  TEST_EXPERIMENTAL run and location_extended is the one skipped.

Resolver check: 57 nodes ordered, no cycle. Selection drops location_site
without the flag, and location_site plus location_extended with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tasks): derive demo schema load order from .metadata.yml

Packets: STRUCT-13
Checklist: CC-1

CC-1 makes .metadata.yml the normative dependency graph, and this task
hard-codes a second, hand-ordered copy of it that already contradicts it:
extensions/circuit sat six positions before its own declared dependency
extensions/location_minimal, and only survived because circuit.yml happens to
reference no location kind. Route the list through the resolver the file
already contains and drop the TEMPORARY marker; the [DEMO] docstring says what
the task is.

Order produced: base, aggregate, cable, circuit, compute, lag,
location_minimal, vlan, vrf, cluster, rack, qinq, hosting_cluster. Every
declared edge respected, and the closure of the 13 entries adds nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: correct stale and copy-pasted descriptions and comments

CC-8 (descriptions and documentation): stale or copy-pasted text is a defect,
and every node and generic carries a description.

- PIM interface no longer described as an OSPF interface (routing_pim/pim.yml)
- RoutingAggregateRoute.destination no longer called a static route
- "A routing policiers for X" corrected in the four routing_policies_* files
- IXP-level import/export policies and BGP communities described at IXP scope,
  not session scope (peering_ixp)
- "Fully operational and currently in connected" fixed in cable, cross_connect
- "Type of WDM (e.g CWDM. DWDM)" fixed three times in dwdm
- "Redifine" fixed in lag
- DcimModuleBay.device comment now states the real peer, DcimPhysicalDevice
- DcimPhysicalDevice.name comment explains why the attribute is duplicated
- DcimConnector description records the cost of kind Attribute on many
- vrrp.yml computed-name note points at IpamCVLAN.name instead of the old
  group + ip field names
- bucket (b) markers reworded as guidance: base/dcim.yml sub-interface hint,
  vrf.yml namespace block, the four "TODO: extend if need" markers
- IpamPrefixScope drops VRF from its example scopes and gains a label;
  IpamVLANGroupScope drops Rack
- IpamVRF.enforce_unique documented as declarative intent
- DcimCircuitEndpoint description no longer points at the removed meet_me_room
- grammar on IpamVLAN, IpamVRF, OrganizationGeneric, LocationGeneric
- .metadata.yml: firewall_policer copy-paste, qinq and snmp stale text
- 13 missing node and generic descriptions added (dwdm x3, lag, mlag,
  location_minimal x3, location_site, patch_panel x3, vlan)
- em dash removed from line 5 of the disclaimer header in all 56 schema files
- orphan docs/docs/reference/modules.mdx deleted (STRUCT-04)

58 files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: normalize choice values, labels and status colors

CC-5 (dropdown and choice hygiene): choice names are lowercase snake_case,
every choice carries a label, and status choices reuse the base palette.

- connector_type: 28 UPPERCASE values lowercased in dwdm and patch_panel
  (URM_P4, URM_P8 and Splice included)
- cable_type: mmf-om1..om5, smf-os1/os2, dac-passive, dac-active, mrj21-trunk
- cable color: dark-red, dark-purple, light-blue, dark-green, light-green,
  dark-orange, light-grey, dark-grey
- routing_policies policy_type: import-policy, export-policy,
  import-export-policy
- users permissions: read-only
- peering_ixp status: pre-maintenance, post-maintenance
- snmp security_level: noAuthNoPriv, authNoPriv, authPriv
- labels added to the two choice sets that had none: the virtual machine role
  in compute, permissions in users
- status colors aligned to the base palette (#00d25b active): mlag interface,
  routing protocol, both peering_ixp status sets, physical disk. Non-status
  colors, including the routing lavender, are untouched.

The matching object files are updated in the same commit so the demo data
still loads: dwdm, patch_panel, routing_policies_{aggregate,bgp,ospf,pim},
snmp, users.

19 files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: declare relationship kind explicitly everywhere

CC-7 asks for kind and cardinality on every relationship. Twelve relationships
still relied on the implicit default; each now states the kind it already
resolves to at runtime, so nothing changes behaviour.

- base/ipam.yml IpamIPAddress.interface gets kind Attribute, matching
  InterfaceLayer3.ip_addresses on the other end of ip_address__interface
  (DCIM-15 and IPAM-12 reached the same ruling)
- kind Generic written out on: OrganizationProvider.circuits and
  LocationHosting.circuit_endpoints (circuit), DcimCircuit.contract
  (circuit_contract), ComputeGenericUnit.worker_in_cluster (cluster),
  RoutingBGPSession.device and OrganizationGeneric.asn (routing_bgp),
  InterfacePhysical.plugged_sfp, LocationHosting.spare_sfps and
  OrganizationManufacturer.sfps (sfp), IpamRouteTarget.import_vrf and
  .export_vrf (vrf)

No Attribute/Generic flips beyond the ip_address__interface pair.
SnmpClient.community already gained its kind earlier in the review.

7 files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: normalize order_weight to the base scheme

CC-9 asks that every attribute and relationship carry an order_weight on the
base scheme: Parent 900, name 1000, description 1100, status 1200, role
1300-1400, tags 2000. After this commit no attribute or relationship in base/
or extensions/ is missing a weight, and no two fields on a resolved node share
one (checked with inheritance and extensions: blocks applied, state: absent
honoured).

- 106 missing weights filled, on attributes and on relationships, including the
  ones the review commits added: DcimInterface.mtu keeps a slot of its own,
  DcimDeviceWithPhysicalDisks.name, DcimPhysicalDevice.name,
  IpamIPAddress.interface, and the four routing_policies_* reverse
  relationships
- scheme slots restored where they had drifted: description 2000 to 1100 in
  base/dcim.yml, dwdm and patch_panel; description 1200 to 1100 in
  base/location.yml, base/organization.yml, DcimPlatform, DcimGenericModule
  and vrf; tags 3000 to 2000 in organization, rack, peering_ixp (twice) and
  bgp_community; LocationGeneric.name 900 to 1000; status to 1200 on DcimDevice,
  IpamPrefix, IpamIPAddress, DcimCable, LocationSite (both files),
  ComputePhysicalServer, ClusterHosting, DcimGenericSFP and RoutingProtocol
- Parent relationships moved to 900: DcimInterface.device, MlagInterface,
  DcimPhysicalDisk.device, RoutingProtocol.device, RoutingOSPFInterface.ospf,
  and the three new ones on RoutingPIMInterface, PeeringIXPConnection and
  UserAccount
- sibling ties broken: VirtualizationVirtualMachine vcpu/memory/disk (all
  1900), DcimPhysicalDevice position/serial_number/location (all 1500, with
  rack_face at 1515), GenericMlagDomain peer_links/mlag_interfaces,
  PeeringIXPConnection ipv6_address/router, RoutingOSPFInterface area/interface
- the IpamGenericVLAN 900/910/920/930 ladder rebased onto the scheme
- IPAM-13 applied to base/ipam.yml: IpamPrefix description 1100, status 1200,
  role 1300, scope 1500; IpamIPAddress description 1100, status 1200, role
  1300, fqdn 1400, primary_of 1500, interface 1600. IpamIPAddress.vrrp moves to
  1700 so it clears the pinned interface slot, and tenancy's IpamIPAddress.tenant
  to 1350 so it clears role

Weights only. No attribute, relationship, choice or flag changes.

40 files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: state optional explicitly on every attribute and relationship

CC-6 asks for `optional` in writing everywhere, because the default is not the
same on both sides: live 1.11.0 treats an omitted `optional` as false on an
attribute and true on a relationship, which is easy to read the wrong way in a
file. Each of the 98 places that relied on the default now states the value it
already resolves to.

- 69 attributes gain `optional: false`, 29 relationships gain `optional: true`
- the value written is the current effective one, verified field by field: all
  406 attributes and relationships in base/ and extensions/ resolve to exactly
  the same `optional` before and after this commit
- `optional: false` stays next to `default_value` rather than being dropped
  (decision Q1a), and base/dcim.yml and base/ipam.yml each gain one note at
  their first status definition recording why: Infrahub 1.11 treats an attribute
  with a default_value as optional at runtime, so `optional: false` is there to
  document intent
- patch_panel connector_type gets its explicit `optional: false`, the second
  half of DWDM-03
- `state: absent` entries are left alone, and experimental/ is out of scope per
  the tier rule

31 files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: state menu intent explicitly and give routing nodes a menu path

CC-9 has two halves: the menu intent of every node and generic is written in
the file, and every concrete node is reachable from the menu. Both land here,
because the routing fix is made of the same keys.

include_in_menu written out (50 nodes and generics)

Every definition in base/ and extensions/ now states include_in_menu. The 50
that relied on the default gain `include_in_menu: true`, the value they already
resolve to, so users can read the menu shape out of the files and later move it
to a menu.yml. experimental/ is out of scope per the tier rule.

Menu placement (CC-3, both live-tested against 1.11.0 on :55000 earlier)

- IpamAggregate moves from menu_placement OrganizationRIR to BuiltinIPAM, where
  an IPAM object belongs
- OrganizationRIR gains menu_placement OrganizationGeneric, matching
  Manufacturer and Provider

Routing menu path (RPOL-CC9)

Ten concrete routing nodes were hidden with no menu_placement and no visible
generic to reach them. Three of them inherit neither routing generic, so making
the generics visible is not enough on its own. The fix is this repo's own
dominant shape, used by SFP, location, organization, SNMP, patch panel and
OADM: a visible generic root plus explicit menu_placement on every concrete
node.

- RoutingProtocol becomes visible and is relabelled from "Protocol" to "Routing
  Protocol", which is what a sidebar root has to say
- RoutingPolicy becomes visible and gains the label "Routing Policy" it never
  had
- menu_placement RoutingProtocol on RoutingAggregateRoute, RoutingOSPF and
  RoutingPIM; RoutingOSPFInterface under RoutingOSPF and RoutingPIMInterface
  under RoutingPIM; RoutingBGPRRCluster under the existing RoutingBGPSession
  root; the four RoutingPolicy flavours under RoutingPolicy

The sidebar grows by two collapsed top-level entries, not by ten. Every
placement target sits in the node's own declared dependency closure, so no
extension can produce a dangling menu_placement.

35 files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(routing_bgp): make RoutingBGPSession.role optional

Packet BGP-* / checklist CC-6: role is optional with no default value, and
RoutingBGPSession.role is the attribute the rule was written against. The
explicit-optional sweep only recorded the current effective value, so the
semantic flip is made here on its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(objects): unquote DcimModuleBay positions

Packet MOD-04 (parked objects finding, review brief item a): the schema commit
turned DcimModuleBay.position into a Number with min_value 1, but the mock data
still carried "1"/"2"/"3" as quoted strings, which load as Text. The PSU bays
already used unquoted 4/5 and are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(objects): reference cardinality-many peers by HFID list

Parked objects finding (review brief item b): on infrahub-sdk 1.17.0 a bare
string in a cardinality-many relationship list is sent as a node ID, not as a
human_friendly_id, so every one of these references failed to resolve. An HFID
is always a list of its parts, so single-part ones are wrapped in a list too.
The comments that asserted flat strings were enough are corrected with them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(objects): stop circuit restating the location_minimal region tree

Parked objects finding (review brief item c): the circuit mock data re-declared
the Americas -> United States -> NYC1/SJC1 hierarchy that
objects/extensions/location_minimal already owns, and re-upserting a
hierarchical root fails. The endpoints now reference the existing sites by
LocationHosting's name__value human_friendly_id instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: regenerate schema reference pages

Output of `uv run invoke docs.generate` after the 56 schema and metadata
commits on this branch. Covers the renames (device_types/platforms,
OrganizationTenant, serial_number, private, facility, IpamGenericVLAN), the new
human_friendly_id and dependency declarations, and the .metadata.yml
description rewrites, plus the sweeps' order_weight/optional/include_in_menu.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(objects): correct stale PSU bay comment and align duplicated provider descriptions

M-TEXT leftovers: the device_psu_module header announced three PSU bays while the
file creates two (PSU-01); circuit.yml re-upserts Lumen and Colt with descriptions
that drifted from objects/base/01_organization.yml, so whichever file loaded last
won. Checklist: CC-8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: keep node and attribute descriptions within the 128 character limit

Infrahub caps `description` at 128 characters. Three descriptions added by
review commit 351f655 were over the cap, and the first one aborted the whole
base load on a fresh 1.11.0 instance before any node was created:

  base/dcim.yml            generic DcimConnector           212 chars
  extensions/circuit       node DcimCircuitEndpoint        208 chars
  extensions/vrf           attribute enforce_unique        146 chars

Each description keeps its factual core; the UI-behaviour half moves to a
YAML `#` comment above the field, which has no length limit and stays next to
the definition it explains.

A scan of every `description` and `label` in base/, extensions/ and
experimental/ (recursive walk of the parsed YAML) reports zero remaining
values over 128 characters, so these three were the only offenders.

Verified on a fresh infrahub-testcontainers 1.11.0 instance: `infrahubctl
schema load base` now reports "4 schemas processed in 7.106 seconds" where it
previously failed with "Unable to load the schema".

Reference pages regenerated with `invoke docs.generate`. The three
hand-written mutual-exclusion notes in extensions/*/README.md are restored
afterwards because the generator overwrites them with the pointer template.

Checklist: CC-8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(physical_disk): make the device name resolvable for the disk HFID

Review commit 0ffcd60 gave DcimPhysicalDisk a human_friendly_id of
[device__name__value, name__value] to match its uniqueness_constraints, but
left the peer generic DcimDeviceWithPhysicalDisks with a plain `name`. Infrahub
only accepts an HFID that walks a peer when the referenced attribute
combination on that peer is unique, so a fresh 1.11.0 instance rejected the
extension outright:

  HFID of DcimPhysicalDisk refers to peer DcimDeviceWithPhysicalDisks with a
  non-unique combination of attributes ['name__value']

`unique: true` on that attribute closes the gap. The library ships no
implementer of this generic (it is documented in .metadata.yml as one you apply
to your own device kind), and every device kind here inherits DcimGenericDevice,
which already declares name unique, so no shipped node changes behaviour. The
existing `allow_override: any` still lets an implementer restate the attribute.
This mirrors DcimPhysicalDevice.name in base/dcim.yml, which carries the same
unique + allow_override pair and the same explanatory comment.

Verified on a fresh infrahub-testcontainers 1.11.0 instance: `infrahubctl
schema load extensions/physical_disk` reports "1 schema processed in 4.891
seconds"; it previously aborted on the HFID error above.

Checklist: CC-4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(objects): make circuit objects self-sufficient for standalone loads

objects/extensions/circuit loads at schema-dependency position 2 but referenced
LocationSite NYC1 and SJC1, which objects/extensions/location_minimal only
creates at position 7. On a fresh 1.11.0 instance the file aborted with:

  Unable to find the node NYC1 / LocationHosting

DcimCircuitEndpoint.location is mandatory and its peer LocationHosting is a base
generic that base itself never implements, so the fixture has to bring its own
sites. They are created with name and description only, without a parent:
extensions/location_site has no tier above Site and extensions/location_minimal
puts sites under Americas -> United States, so restating either tree would tie
this file to one of the two mutually exclusive location extensions. Restating
location_minimal's is not possible in any case, re-upserting the hierarchy root
fails with "Not supported to assign a value to parent for LocationRegion".

Declaring extensions/location_site as a dependency in .metadata.yml (the way
extensions/rack does, which would move circuit behind the location fixture) was
rejected: CC-1 dropped circuit -> location_minimal as a declared-but-unused
edge, and circuit.yml still references no LocationSite kind, only the base
LocationHosting generic.

On the parent question the brief raised: a partial LocationSite upsert does
clear `parent`, confirmed directly (load location_minimal, upsert
{name: NYC1, description: ...}, parent goes from "United States" to null). It is
not a consequence of this change. objects/extensions/sfp, cross_connect, vlan,
compute, dwdm and hosting_cluster already carry fifteen nested
{kind: LocationSite, data: {name: ...}} blocks that do exactly the same thing,
several of them after location_minimal in the load order, so NYC1 and SJC1 end
a full ordered load without a parent with or without this commit. This file
adds no new exposure: at position 2 it runs before every other file that
touches a site. Cleaning up the fifteen pre-existing blocks (a plain HFID
scalar resolves against LocationHosting and does not re-save the node) is a
separate sweep.

Verified on a fresh infrahub-testcontainers 1.11.0 instance: objects/base plus
all 34 objects/extensions directories load in resolver order with zero errors,
35/35, where objects/extensions/circuit previously failed. GraphQL then reports
DcimCircuit 3 and DcimCircuitEndpoint 4.

Checklist: CC-1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(objects): stop later files clearing the MLAG domain membership

Mechanism, read out of infrahub-sdk 1.17.0 and then confirmed on a live
instance. `infrahubctl object load` builds every record from scratch and saves
it with allow_upsert. In InfrahubNodeBase._generate_input_data
(node/node.py:213) a relationship that the document leaves out is treated three
different ways: cardinality-many is skipped, mandatory cardinality-one is
skipped, and OPTIONAL cardinality-one is written into the payload as an
explicit None. A partial re-upsert therefore clears exactly the optional
cardinality-one links, and `create()` sends that payload unfiltered because
allow_upsert never passes exclude_unmodified.

DcimDevice.mlag_domain is optional cardinality one and shares the identifier
device__mlag_domain with MlagDomain.peers, which is min_count/max_count 2.
objects/extensions/mlag wired nyc1-rtr01 and nyc1-rtr02 into NYC1-MLAG-01 at
resolver position 16; objects/extensions/rack (17), device_psu_module (18),
dwdm (22), routing_ospf (28) and routing_pim (29) each re-upsert nyc1-rtr01
afterwards without it, and all five failed with:

  ['DcimDeviceUpsert'] Node <id> has 0 peers for device__mlag_domain,
  no fewer than 2 allowed

Restating mlag_domain in those five files was rejected: objects/extensions/rack
and four others would then fail whenever extensions/mlag is not loaded, which
inverts the layering and contradicts the self-sufficiency rule applied to the
circuit fixture. Referencing the device instead of re-declaring it is not
available either, DcimModuleBay.device peers with DcimPhysicalDevice, which
declares no human_friendly_id, so the nested {kind, data} block that re-saves
the device is the only way to express that link.

So the fixture owns its own endpoints. The domain is now built on nyc1-rtr02
and a new nyc1-rtr03, and this file is the only one anywhere under objects/
that ever saves either of them; nyc1-rtr01 is left to the files that own it.
objects/extensions/routing_bgp and peering_ixp are unaffected, they reference
nyc1-rtr02 by human_friendly_id, which does not re-save it. Both peers moved
into a single document so the LocationRack block can be shared through a YAML
anchor, which does not cross a `---` boundary.

The upsert rule is written out once, in this file's header, with the failing
error text, so a future file re-declaring a linked node has somewhere to look.
objects/extensions/circuit and objects/extensions/vrrp point at it.

Verified on a fresh infrahub-testcontainers 1.11.0 instance: all 35 object
directories load in resolver order with zero errors, and GraphQL reports
NYC1-MLAG-01 with peers nyc1-rtr02 and nyc1-rtr03, two peer_links and
MlagInterface 2.

Checklist: CC-1, CC-7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(objects): keep the VRRP fixture on interfaces it owns

Same defect family as the MLAG one, uncovered once that fix let
objects/extensions/rack get past its device upsert. rack then failed on the
nested interface instead:

  ['InterfacePhysicalUpsert'] Node <id> has -1 peers for
  interfacelayer3__networkvrrpinterface, no fewer than 1 allowed

extensions/vrrp adds InterfaceLayer3.vrrp as an optional cardinality-one
relationship, and its counterpart NetworkVRRPInterface.interface is mandatory.
objects/extensions/vrrp (resolver position 14) put NYC1-CORE-VRRP on
GigabitEthernet0/0/0/0, which objects/extensions/rack (17) owns and re-declares
afterwards; the re-declaration nulls the optional side and would orphan the
NetworkVRRPInterface, so the server refuses the mutation.

The group moves to GigabitEthernet0/0/0/3, which nothing else creates or
touches, so all three VRRP groups now sit on interfaces this file creates
itself (/1, /2, /3). The header records why /0 is off limits and points at the
upsert rule in objects/extensions/mlag/mlag.yml. The stale claim that
GigabitEthernet0/0/0/0 "already exists (from extensions/rack)" goes with it:
rack loads after this file, sfp is what creates that interface earlier.

A sweep of the live 1.11.0 schema for the same shape (an optional
cardinality-one relationship whose counterpart is mandatory cardinality-one)
found only two others: InterfaceLag.vrrp, which no fixture uses, and
DcimModuleBay.installed_module, where objects/extensions/device_psu_module
creates fresh bays instead of reusing the occupied ones.

Verified on a fresh infrahub-testcontainers 1.11.0 instance: objects/base plus
all 34 objects/extensions directories load in resolver order with zero errors,
35/35. GraphQL reports three NetworkVRRPInterface records on
GigabitEthernet0/0/0/1, /2 and /3.

Checklist: CC-7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(docs): keep the trailing newline when generating the home page

The markdown lint CI job failed with a single MD047 violation on
docs/docs/home.mdx, which is generated from docs/_templates/home_page.j2
by _generate_home_page_documentation().

The template ends with a newline, but the renderer built its environment
as jinja2.Environment(trim_blocks=True), and keep_trailing_newline
defaults to False, so Jinja stripped the template's final newline from
the rendered output. Regenerating the docs therefore reproducibly emits a
home page that markdownlint rejects.

Set keep_trailing_newline=True and regenerate. The flag only suppresses
the lexer's end-of-source newline strip, so the output is the previous
output plus one newline, and nothing else changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: convert remaining TODO markers to issues

extensions/vrf/vrf.yml -> #82
tasks/schemas.py (schema-less extension dir) -> #83
tasks/schemas.py (loader return value) -> #84
tasks/docs.py (hardcoded base link, trailing comment only) -> #85
extensions/routing/routing.yml -> #86

Drafts 1 (#81), 7 (#87) and 8 (#88) have no marker to delete.

Checklist: CC-8

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: drop the default_value optionality note from the base schemas (review suggestion)

Review threads: base/dcim.yml:169-170 and base/ipam.yml:53-54 (BaptisteGi). The note
claimed Infrahub 1.11 treats an attribute with a default_value as optional at runtime,
which is not documented in any release note, and it sat at the top of the attributes
block rather than on the attribute it described.

The explicit `optional: false` lines it was meant to justify stay as they are (CC-6).
Both occurrences in the repository are removed; a grep for the same claim finds no
others.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: order uniqueness_constraints most selective first (review suggestion)

Review threads: base/ipam.yml:51, base/ipam.yml:123 and
extensions/cross_connect/cross_connect.yml:26 (BaptisteGi), citing
https://docs.infrahub.app/schema/nodes-and-attributes#optimizing-uniqueness-constraints
"Order uniqueness constraint elements from most selective (returning fewer matches) to
least selective (returning more matches) to optimize database query performance."

A prefix value, an IP address and a provider circuit identifier each return far fewer
matches than the namespace or the provider they hang off, so they come first. This
reverts the ordering criterion used in fc52a80, which ordered the declared constraint to
match the namespace-first human_friendly_id instead. Consequence, kept deliberately:
because the declared order no longer matches the HFID, Infrahub adds its HFID-derived
constraint on top, so the live schema carries two functionally identical constraints on
IpamPrefix, IpamIPAddress and DcimCrossConnect. The HFIDs themselves are unchanged
(IPAM-07).

Packets: IPAM-07, base_ipam consistency note 3. Checklist: CC-4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: apply selective-first uniqueness ordering across the schema files (generalizing review suggestion)

Generalizes the review thread on base/ipam.yml:51 (BaptisteGi) to every
uniqueness_constraints entry in base/ and extensions/: 24 entries reviewed, 1 more
changed.

IpamCVLAN was flipped to svlan-first in b26063a under the same rejected criterion
(match the human_friendly_id). QinQ has few S-VLANs each encapsulating many C-VLANs, so
a C-VLAN id returns fewer matches than an S-VLAN does, and value-first also matches its
siblings IpamVLAN and IpamSVLAN, which both read [vlan_id__value, vlan_group].

The remaining parent-first entries stay as they are: they scope a child to a parent that
is itself numerous (interfaces per device, ports per patch panel, endpoints per circuit,
disks per device), where the parent is the more selective side and the child name repeats
across parents.

Packets: QINQ-02, base_ipam consistency note 3. Checklist: CC-4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: use >- for every description that does not fit on one line (review suggestion)

Review thread: extensions/circuit/circuit.yml:106 (BaptisteGi), "In some places we have
`>-` for long YAML string, sometime we don't".

Rule adopted, which is the one the repository already follows almost everywhere and the
one yamllint can back: a description stays on a single line while it fits inside the
120-character limit from .yamllint.yml, and uses the folded block scalar `>-` when it
does not. Swept all 44 schema files in base/ and extensions/ plus objects/ and the
.metadata.yml files: 496 descriptions, 492 single-line (longest 119 characters, none over
the limit), 4 wrapped. The file Baptiste commented on is already correct, and two
deviants needed fixing:

- base/ipam.yml:17 wrapped as a plain multi-line scalar (implicit folding) instead of >-.
- extensions/dwdm/dwdm.yml:122 used the literal block `|`, which kept a newline inside a
  prose description that has no meaningful line breaks.

Checklist: CC-8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cable): keep DcimCable.label optional while it backs the HFID (review suggestion)

Review thread: extensions/cable/cable.yml:221-225 (BaptisteGi), "I think forcing a label
on every cable is quite optimistic, especially if it has to be entered manually by
someone. I agree on the other hand not having a HFID is quite annoying when playing with
the API".

26280ad made the label mandatory to back the new human_friendly_id. The mandatory half is
not needed: the HFID validator requires the attribute to be unique, not mandatory (only
relationship components of an HFID must be mandatory, per
docs.infrahub.app/schema/nodes-and-attributes#human-friendly-identifier-hfid). So the
label goes back to optional, keeps unique: true, and the HFID stays. A cable with no
label simply has no HFID until someone labels it.

Packets: CABLE-02. Checklist: CC-4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ipam): drop the supernet prefix role now that aggregates have their own node (review suggestion)

Review thread: base/ipam.yml:89-92 (BaptisteGi), suggestion block deleting the choice,
"As discussed we now have a dedicated aggregate extension".

extensions/aggregate models an RIR-assigned top-level prefix as IpamAggregate, so
representing the same thing as a role on a generic prefix is a second way to say it. No
objects file uses role: supernet, so no fixture changes. Swept the other four choice
lists in base/ (device status and role, interface status and role, prefix and address
status, address role): no other choice value duplicates a node kind shipped by an
extension.

Packets: IPAM-04 (prefix roles), ext_ipam digest Q2. Checklist: CC-5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: regenerate schema reference pages

Follows the review fixes in this branch: `uv run invoke docs.generate`, with the three
hand-edited extension READMEs (rack, location_minimal, location_site) restored afterwards
because the generator overwrites them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(device_module): restore a status on modules

Packet MOD-07 flagged that v2's DcimGenericModule lost the status the v1
extension carried, and that with module_bay mandatory there is no way to
record a module that has failed other than deleting the object.

Baptiste's answer: "Indeed, could be nice having a status field".

Restores status on DcimGenericModule, following the conventions:

- kind Dropdown, optional written explicitly, mandatory with
  default_value active (CC-6)
- choices snake_case with a label, a description and a color; colors are
  base-palette values, taken verbatim from DcimDevice.status in
  base/dcim.yml where the state matches (provisioning #f0ad4e,
  active #00d25b, maintenance #ff9800) and from the same palette for the
  two new states (#e04040, #6c757d) (CC-5)
- order_weight 1200, the base scheme's slot for status (CC-9)

Choice set, against the v1 baseline (extensions/modules/modules.yml on
main: provisioning, active, maintenance, disabled, outage): the first
three are kept as-is. v1's disabled and outage are replaced by failed and
rma, which is the case the packet was actually about: a module that is
still recorded against its last known bay while it is out of service or
away for replacement. deprecated is deliberately not included, as
end-of-life belongs to the module type rather than to an installed
instance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(users): remove the account model pending a field-driven design

Packet USER-01 asked what UserAccount is for, given that Infrahub already
ships builtin accounts and groups: the extension models device login
credentials (password, ssh_key, mfa_enabled, a permissions dropdown) under
a name that reads like Infrahub's own user management, and nothing in the
library consumes it.

Baptiste's answer: "I agree it's confusing, perhaps we could just remove
it and provide a new implementation when someone face it on the field
(would need to try if reusing builtin account/group is doable/wanted)".

Scope executed: the whole extensions/users extension, not UserAccount
alone. UserGroup exists only to group accounts (its idle_timeout and
permissions describe an account's session on a device), and the
OrganizationGeneric.accounts extension block has UserAccount as its peer,
so neither has a purpose once the account model is gone. Removing
UserAccount alone would leave a group node grouping nothing and a dangling
extension block.

Nothing else references the two kinds: base/, extensions/ and
experimental/ are clean, so there is no cross-extension breakage and no
experimental extension left pointing at a missing peer.

Removed:
- extensions/users/users.yml and its generated README
- the extensions/users entry in .metadata.yml
- objects/extensions/users/users.yml (its mock data)

The generated docs page and home page listing are regenerated separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: regenerate schema reference pages

Runs `invoke docs.generate` for the two schema changes in this series:

- device_module.mdx picks up the new DcimGenericModule.status attribute
- home.mdx drops the Users row, following the removal of the
  extensions/users entry from .metadata.yml

The generator never prunes, so docs/docs/reference/users.mdx is deleted by
hand.

The hand-written notes in the rack, location_minimal and location_site
READMEs are restored: docs.generate overwrites every extension README with
the three-line pointer template, and those three carry exclusivity notes
that CC-1 requires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(objects): move the Acme Networks organization into the base mock data

Fallout from removing extensions/users, caught by a full object load:
objects/extensions/users/users.yml was the only file creating the
OrganizationProvider "Acme Networks", as a side effect of upserting the
organization its three accounts pointed at.

objects/extensions/routing_bgp/routing_bgp.yml uses that record for
AS 65000, our own backbone AS, and its header comment already documents it
as coming from base. Without it the load fails with "Unable to find the
node Acme Networks / OrganizationGeneric in the database", and
objects/extensions/peering_ixp fails after it because it needs the AS that
never got created.

Adds the record to objects/base/01_organization.yml, where routing_bgp
already says it lives. OrganizationProvider is the kind extensions/users
used and the only concrete OrganizationGeneric kind base ships, so base
mock data cannot express it any other way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

@lancamat1 lancamat1 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. For the record of what this review covered:

142 modelling decisions extracted from the net diff and judged against a checklist agreed up front. 81 accepted as they are, 58 landed as changes through #80 (now merged), one revert (mtu back on the interface generic), and the six open questions were settled with Baptiste in Slack: module status restored, users extension removed pending a field-driven design, supernet role dropped, and gateway, os_version and the platform driver fields stay removed as intentional minimalism. Experimental extensions got a hard-flaw sweep only, no style review.

Verified end to end: every schema and every objects file loads into a fresh Infrahub 1.11.0 with zero errors. Follow-ups that did not belong in v2.0 are filed as #81 to #88.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants