diff --git a/docs/v2/v2-release-readiness.md b/docs/v2/v2-release-readiness.md
index bca3373..6a1bc85 100644
--- a/docs/v2/v2-release-readiness.md
+++ b/docs/v2/v2-release-readiness.md
@@ -38,7 +38,7 @@ Remaining Stream C surfaces (hardening, not release-blocking):
- ~~CreateMonitoredItems + TransferSubscriptions gating with per-item `(AuthGenerationId, MembershipVersion)` stamp so revoked grants surface `BadUserAccessDenied` within one publish cycle (decision #153).~~ **Partial, 2026-04-24.** `DriverNodeManager.CreateMonitoredItems` override pre-gates each request and pre-populates `BadUserAccessDenied` into the errors slot for denied items (the base stack honours pre-set errors and skips those items). Decision #153's per-item `(AuthGenerationId, MembershipVersion)` stamp for detecting mid-subscription revocation is still to ship — needs subscription-layer plumbing. TransferSubscriptions not yet wired (same pattern).
- ~~Alarm Acknowledge / Confirm / Shelve gating.~~ **Partial, 2026-04-24.** Acknowledge + Confirm map to dedicated `OpcUaOperation.AlarmAcknowledge` / `AlarmConfirm` via `MapCallOperation`; Shelve falls through to generic `OpcUaOperation.Call` (needs per-instance method NodeId resolution to distinguish — follow-up).
- ~~Call (method invocation) gating.~~ **Closed 2026-04-24.** `DriverNodeManager.Call` override pre-gates each `CallMethodRequest` via `GateCallMethodRequests`. Denied calls return `BadUserAccessDenied` without running the method. Alarm methods map to alarm-specific operation kinds; everything else gates as generic `Call`.
-- Finer-grained scope resolution — current `NodeScopeResolver` returns a flat cluster-level scope. Joining against the live Configuration DB to populate UnsArea / UnsLine / Equipment path is tracked as Stream C.12.
+- ~~Finer-grained scope resolution — current `NodeScopeResolver` returns a flat cluster-level scope. Joining against the live Configuration DB to populate UnsArea / UnsLine / Equipment path is tracked as Stream C.12.~~ **Partial, 2026-04-24.** `ScopePathIndexBuilder` + indexed-mode `NodeScopeResolver` exist and are unit-tested — index keys driver-side full-ref → full `Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag` scope. **Critical follow-up (task #133):** Program.cs does not yet construct either the gate or the resolver — all six dispatch-layer gates (Read, Write, HistoryRead, Browse, CreateMonitoredItems, Call) are currently inert in production. Wiring is required before GA.
- 3-user integration matrix covering every operation × allow/deny.
### ~~Config fallback — Phase 6.1 Stream D wiring~~ (task #136 — **CLOSED** 2026-04-19, PR #96)
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ScopePathIndexBuilderTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ScopePathIndexBuilderTests.cs
new file mode 100644
index 0000000..f703848
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ScopePathIndexBuilderTests.cs
@@ -0,0 +1,148 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+using ZB.MOM.WW.OtOpcUa.Core.Authorization;
+using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
+using ZB.MOM.WW.OtOpcUa.Server.Security;
+
+namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
+
+///
+/// Unit tests for — the ADR-001 Task B builder that
+/// produces the full-path index consumed by
+/// in its indexed mode.
+///
+[Trait("Category", "Unit")]
+public sealed class ScopePathIndexBuilderTests
+{
+ [Fact]
+ public void Build_emits_full_hierarchy_for_well_formed_content()
+ {
+ var index = ScopePathIndexBuilder.Build("c1", "ns-eq", Content(
+ areas: [Area("area1")],
+ lines: [Line("line1", "area1")],
+ equipment: [Equip("eq1", "line1")],
+ tags: [TagRow("tag1", "eq1", tagConfig: "Eq1/Speed")]));
+
+ index.Count.ShouldBe(1);
+ var scope = index["Eq1/Speed"];
+ scope.ClusterId.ShouldBe("c1");
+ scope.NamespaceId.ShouldBe("ns-eq");
+ scope.UnsAreaId.ShouldBe("area1");
+ scope.UnsLineId.ShouldBe("line1");
+ scope.EquipmentId.ShouldBe("eq1");
+ scope.TagId.ShouldBe("Eq1/Speed");
+ scope.Kind.ShouldBe(NodeHierarchyKind.Equipment);
+ }
+
+ [Fact]
+ public void Build_skips_tags_with_null_EquipmentId()
+ {
+ // SystemPlatform-namespace tags (decision #110) — the cluster-only resolver
+ // fallback handles them; no index entry needed.
+ var index = ScopePathIndexBuilder.Build("c1", "ns-sp", Content(
+ tags: [TagRow("t", equipmentId: null, tagConfig: "Galaxy.Object.Attr")]));
+
+ index.Count.ShouldBe(0);
+ }
+
+ [Fact]
+ public void Build_skips_tags_with_broken_Equipment_FK()
+ {
+ // Tag references a missing Equipment row. sp_ValidateDraft should have caught this
+ // at publish; builder skips rather than crashes so startup stays bootable.
+ var index = ScopePathIndexBuilder.Build("c1", "ns", Content(
+ areas: [Area("area1")],
+ lines: [Line("line1", "area1")],
+ tags: [TagRow("t", "missing-eq", "missing/Speed")]));
+
+ index.Count.ShouldBe(0);
+ }
+
+ [Fact]
+ public void Build_skips_equipment_with_broken_line_FK()
+ {
+ var index = ScopePathIndexBuilder.Build("c1", "ns", Content(
+ areas: [Area("area1")],
+ lines: [], // no lines — equipment's UnsLineId misses
+ equipment: [Equip("eq1", "missing")],
+ tags: [TagRow("t", "eq1", "E/S")]));
+
+ index.Count.ShouldBe(0);
+ }
+
+ [Fact]
+ public void Build_throws_on_duplicate_TagConfig()
+ {
+ var ex = Should.Throw(() =>
+ ScopePathIndexBuilder.Build("c1", "ns", Content(
+ areas: [Area("area1")],
+ lines: [Line("line1", "area1")],
+ equipment: [Equip("eq1", "line1")],
+ tags:
+ [
+ TagRow("t1", "eq1", "E/DUP"),
+ TagRow("t2", "eq1", "E/DUP"),
+ ])));
+
+ ex.Message.ShouldContain("Duplicate");
+ ex.Message.ShouldContain("E/DUP");
+ }
+
+ [Fact]
+ public void Resolver_with_index_returns_full_path_scope()
+ {
+ var index = ScopePathIndexBuilder.Build("c1", "ns", Content(
+ areas: [Area("area1")],
+ lines: [Line("line1", "area1")],
+ equipment: [Equip("eq1", "line1")],
+ tags: [TagRow("t", "eq1", "E/Speed")]));
+ var resolver = new NodeScopeResolver("c1", index);
+
+ var resolved = resolver.Resolve("E/Speed");
+ resolved.UnsAreaId.ShouldBe("area1");
+ resolved.UnsLineId.ShouldBe("line1");
+ resolved.EquipmentId.ShouldBe("eq1");
+
+ // Un-indexed ref falls through to cluster-only scope — pre-ADR-001 behaviour preserved.
+ var fallback = resolver.Resolve("Galaxy.Object.Attr");
+ fallback.ClusterId.ShouldBe("c1");
+ fallback.TagId.ShouldBe("Galaxy.Object.Attr");
+ fallback.UnsAreaId.ShouldBeNull();
+ }
+
+ // ---- fixture helpers ---------------------------------------------------
+
+ private static EquipmentNamespaceContent Content(
+ IReadOnlyList? areas = null,
+ IReadOnlyList? lines = null,
+ IReadOnlyList? equipment = null,
+ IReadOnlyList? tags = null) =>
+ new(areas ?? [], lines ?? [], equipment ?? [], tags ?? []);
+
+ private static UnsArea Area(string id) => new()
+ {
+ UnsAreaId = id, ClusterId = "c1", Name = $"Area {id}",
+ };
+
+ private static UnsLine Line(string id, string areaId) => new()
+ {
+ UnsLineId = id, UnsAreaId = areaId, Name = $"Line {id}",
+ };
+
+ private static Equipment Equip(string id, string lineId) => new()
+ {
+ EquipmentId = id, UnsLineId = lineId, DriverInstanceId = "drv",
+ Name = $"Eq {id}", MachineCode = $"M{id}", ZTag = id,
+ };
+
+ private static Tag TagRow(string id, string? equipmentId, string tagConfig) => new()
+ {
+ TagId = id, EquipmentId = equipmentId,
+ DriverInstanceId = "drv",
+ Name = id, DataType = "Int32",
+ AccessLevel = TagAccessLevel.ReadWrite,
+ TagConfig = tagConfig,
+ };
+}